返回 DeepSeek-Reasonix
acp_test.go
根目录 / internal / cli / acp_test.go
1 package cli
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/acp"
11 "reasonix/internal/config"
12 "reasonix/internal/event"
13 "reasonix/internal/netclient"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16
17 _ "reasonix/internal/tool/builtin"
18 )
19
20 const acpTestProviderKind = "acp-test-provider"
21
22 func init() {
23 provider.Register(acpTestProviderKind, func(cfg provider.Config) (provider.Provider, error) {
24 return &acpTestProvider{cfg: cfg}, nil
25 })
26 }
27
28 func TestACPBuiltinToolsKeepSessionLevelBuiltins(t *testing.T) {
29 dir := t.TempDir()
30 tools := toolMap(acpBuiltinTools(&config.Config{}, dir, []string{dir}))
31 for _, name := range []string{
32 "todo_write",
33 "complete_step",
34 "bash_output",
35 "kill_shell",
36 "wait",
37 "move_file",
38 "notebook_edit",
39 } {
40 if tools[name] == nil {
41 t.Fatalf("ACP workspace tools missing %q; got %v", name, toolNames(tools))
42 }
43 }
44 }
45
46 func TestACPInitializesWithoutAPIKey(t *testing.T) {
47 isolateCLIConfigHome(t)
48 t.Setenv("DEEPSEEK_API_KEY", "")
49 oldStdin := os.Stdin
50 r, w, err := os.Pipe()
51 if err != nil {
52 t.Fatal(err)
53 }
54 _, _ = w.WriteString(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}` + "\n")
55 _ = w.Close()
56 os.Stdin = r
57 t.Cleanup(func() {
58 os.Stdin = oldStdin
59 _ = r.Close()
60 })
61
62 out := captureStdout(t, func() {
63 if rc := Run([]string{"--acp"}, "test-version"); rc != 0 {
64 t.Fatalf("Run --acp initialize rc = %d, want 0", rc)
65 }
66 })
67 if !strings.Contains(out, `"protocolVersion":1`) || !strings.Contains(out, `"name":"reasonix"`) {
68 t.Fatalf("initialize output = %s", out)
69 }
70 }
71
72 func TestACPRejectsInvalidSupervisorFlags(t *testing.T) {
73 for _, args := range [][]string{
74 {"--planner=maybe"},
75 {"--sandbox-network=maybe"},
76 {"--sandbox-bash=maybe"},
77 } {
78 if rc := acpCommand(args, "test-version"); rc != 2 {
79 t.Fatalf("acpCommand(%v) rc = %d, want 2", args, rc)
80 }
81 }
82 }
83
84 func TestACPSupervisorRuntimeStateUsesHardOverrides(t *testing.T) {
85 isolateCLIConfigHome(t)
86 project := t.TempDir()
87 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
88 [agent]
89 planner_model = "configured-planner"
90
91 [sandbox]
92 network = true
93 bash = "off"
94
95 allow_write = ["../outside"]
96 `), 0o644); err != nil {
97 t.Fatal(err)
98 }
99 off := false
100 factory := &acpFactory{
101 plannerOff: true, networkOverride: &off, bashOverride: "enforce", workspaceOnly: true,
102 }
103 state, err := factory.SessionRuntimeState(context.Background(), acp.SessionRuntimeStateParams{
104 Cwd: project, Model: "configured-planner", RuntimeProfile: "balanced",
105 })
106 if err != nil {
107 t.Fatalf("SessionRuntimeState: %v", err)
108 }
109 if state.PlannerMode != "off" || state.Sandbox.Mode != "enforce" || state.Sandbox.NetworkEnabled {
110 t.Fatalf("runtime overrides = %+v", state)
111 }
112 if len(state.Sandbox.WriteRoots) != 1 || state.Sandbox.WriteRoots[0] != project || state.Sandbox.WorkspaceRoot != project {
113 t.Fatalf("workspace confinement = %+v", state.Sandbox)
114 }
115 }
116
117 func TestACPSupervisorRuntimeStateDegradesWhenSandboxIsUnavailable(t *testing.T) {
118 isolateCLIConfigHome(t)
119 project := t.TempDir()
120 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[sandbox]\nbash = \"enforce\"\n"), 0o644); err != nil {
121 t.Fatal(err)
122 }
123 unavailable := func() bool { return false }
124 params := acp.SessionRuntimeStateParams{Cwd: project, RuntimeProfile: "balanced"}
125
126 state, err := (&acpFactory{
127 bashOverride: "enforce", sandboxAvailable: unavailable,
128 }).SessionRuntimeState(context.Background(), params)
129 if err != nil {
130 t.Fatalf("default ACP startup must report unavailable sandbox instead of failing: %v", err)
131 }
132 if state.Sandbox.Mode != "enforce" || state.Sandbox.Available {
133 t.Fatalf("degraded sandbox state = %+v", state.Sandbox)
134 }
135
136 _, err = (&acpFactory{
137 bashOverride: "enforce", requireSandbox: true, sandboxAvailable: unavailable,
138 }).SessionRuntimeState(context.Background(), params)
139 if err == nil || !strings.Contains(err.Error(), "sandbox unavailable") {
140 t.Fatalf("explicit enforce must fail closed, got %v", err)
141 }
142 }
143
144 func TestEffectiveACPPlannerModeMatchesSelectedRuntime(t *testing.T) {
145 cfg := config.Default()
146 cfg.Agent.PlannerModel = "planner/planner-model"
147 cfg.Providers = []config.ProviderEntry{
148 {Name: "executor", Kind: "openai", Model: "executor-model"},
149 {Name: "planner", Kind: "openai", Model: "planner-model"},
150 }
151 if got := effectiveACPPlannerMode(cfg, false, "executor/executor-model", "balanced"); got != "on" {
152 t.Fatalf("balanced split-model planner mode = %q, want on", got)
153 }
154 if got := effectiveACPPlannerMode(cfg, false, "executor/executor-model", "economy"); got != "off" {
155 t.Fatalf("economy planner mode = %q, want off", got)
156 }
157 if got := effectiveACPPlannerMode(cfg, false, "planner/planner-model", "balanced"); got != "off" {
158 t.Fatalf("same-model planner mode = %q, want off", got)
159 }
160 }
161
162 func TestACPFactoryLoadsSessionCwdProjectConfig(t *testing.T) {
163 home := isolateCLIConfigHome(t)
164 if _, err := config.SetCredential("REASONIX_TEST_KEY", "test-key"); err != nil {
165 t.Fatalf("SetCredential: %v", err)
166 }
167 project := t.TempDir()
168 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
169 default_model = "local"
170
171 [[providers]]
172 name = "local"
173 kind = "acp-test-provider"
174 base_url = "http://example.invalid"
175 model = "fake-model"
176 api_key_env = "REASONIX_TEST_KEY"
177 `), 0o644); err != nil {
178 t.Fatal(err)
179 }
180 cmdDir := filepath.Join(project, ".reasonix", "commands")
181 if err := os.MkdirAll(cmdDir, 0o755); err != nil {
182 t.Fatal(err)
183 }
184 if err := os.WriteFile(filepath.Join(cmdDir, "acp-only.md"), []byte("ACP project command"), 0o644); err != nil {
185 t.Fatal(err)
186 }
187 if err := os.Chdir(home); err != nil {
188 t.Fatal(err)
189 }
190
191 ctrl, err := (&acpFactory{}).NewSession(context.Background(), acp.SessionParams{Cwd: project, Sink: event.Discard})
192 if err != nil {
193 t.Fatalf("NewSession: %v", err)
194 }
195 defer ctrl.Close()
196
197 for _, cmd := range ctrl.Commands() {
198 if cmd.Name == "acp-only" {
199 return
200 }
201 }
202 t.Fatalf("ACP session did not load project command from cwd; commands=%v", ctrl.Commands())
203 }
204
205 func TestACPFactoryClearsEffortOverrideForUnsupportedModel(t *testing.T) {
206 isolateCLIConfigHome(t)
207 if _, err := config.SetCredential("REASONIX_TEST_KEY", "test-key"); err != nil {
208 t.Fatalf("SetCredential: %v", err)
209 }
210 project := t.TempDir()
211 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
212 default_model = "reasoner/reasoning-model"
213
214 [[providers]]
215 name = "reasoner"
216 kind = "acp-test-provider"
217 base_url = "http://example.invalid"
218 model = "reasoning-model"
219 api_key_env = "REASONIX_TEST_KEY"
220 supported_efforts = ["low", "high"]
221
222 [[providers]]
223 name = "plain"
224 kind = "acp-test-provider"
225 base_url = "http://example.invalid"
226 model = "plain-model"
227 api_key_env = "REASONIX_TEST_KEY"
228 effort = "high"
229 `), 0o644); err != nil {
230 t.Fatal(err)
231 }
232
233 high := "high"
234 state, err := (&acpFactory{}).SessionConfigState(context.Background(), acp.SessionConfigStateParams{
235 Cwd: project,
236 Model: "reasoner/reasoning-model",
237 EffortOverride: &high,
238 })
239 if err != nil {
240 t.Fatalf("reasoning SessionConfigState: %v", err)
241 }
242 if state.EffortOverride == nil || *state.EffortOverride != "high" {
243 t.Fatalf("reasoning effort override = %v, want high", state.EffortOverride)
244 }
245
246 state, err = (&acpFactory{}).SessionConfigState(context.Background(), acp.SessionConfigStateParams{
247 Cwd: project,
248 Model: "plain/plain-model",
249 EffortOverride: &high,
250 })
251 if err != nil {
252 t.Fatalf("plain SessionConfigState: %v", err)
253 }
254 if _, ok := findACPConfigOption(state.ConfigOptions, "effort"); ok {
255 t.Fatalf("plain model should not advertise effort option: %+v", state.ConfigOptions)
256 }
257 if state.EffortOverride == nil || *state.EffortOverride != "" {
258 t.Fatalf("plain effort override = %v, want explicit empty override", state.EffortOverride)
259 }
260 }
261
262 func TestACPFactoryAdvertisesAndNormalizesRuntimeProfiles(t *testing.T) {
263 isolateCLIConfigHome(t)
264 if _, err := config.SetCredential("REASONIX_TEST_KEY", "test-key"); err != nil {
265 t.Fatalf("SetCredential: %v", err)
266 }
267 project := t.TempDir()
268 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
269 default_model = "local"
270
271 [[providers]]
272 name = "local"
273 kind = "acp-test-provider"
274 base_url = "http://example.invalid"
275 model = "fake-model"
276 api_key_env = "REASONIX_TEST_KEY"
277 `), 0o644); err != nil {
278 t.Fatal(err)
279 }
280
281 state, err := (&acpFactory{profile: "full"}).SessionConfigState(context.Background(), acp.SessionConfigStateParams{
282 Cwd: project,
283 RuntimeProfile: "delivery",
284 })
285 if err != nil {
286 t.Fatalf("SessionConfigState: %v", err)
287 }
288 work, ok := findACPConfigOption(state.ConfigOptions, "work_mode")
289 if !ok || work.CurrentValue != "delivery" || state.RuntimeProfile != "delivery" || len(work.Options) != 3 {
290 t.Fatalf("work mode state = %+v / %q, want delivery with 3 options", work, state.RuntimeProfile)
291 }
292
293 state, err = (&acpFactory{profile: "full"}).SessionConfigState(context.Background(), acp.SessionConfigStateParams{Cwd: project})
294 if err != nil {
295 t.Fatalf("default SessionConfigState: %v", err)
296 }
297 work, _ = findACPConfigOption(state.ConfigOptions, "work_mode")
298 if work.CurrentValue != "balanced" || state.RuntimeProfile != "balanced" {
299 t.Fatalf("legacy full profile = %+v / %q, want balanced", work, state.RuntimeProfile)
300 }
301 }
302
303 func TestACPTaskProfileDefaults(t *testing.T) {
304 cfg := config.Default()
305 cfg.Agent.SubagentModel = "default-model"
306 cfg.Agent.SubagentEffort = "high"
307 cfg.Agent.SubagentModels = map[string]string{"task": "task-model"}
308 cfg.Agent.SubagentEfforts = map[string]string{"task": "max"}
309
310 model, effort := acpTaskProfileDefaults(cfg)
311 if model != "task-model" || effort != "max" {
312 t.Fatalf("task profile defaults = %q/%q, want task-model/max", model, effort)
313 }
314
315 cfg.Agent.SubagentModels = nil
316 cfg.Agent.SubagentEfforts = nil
317 model, effort = acpTaskProfileDefaults(cfg)
318 if model != "default-model" || effort != "high" {
319 t.Fatalf("fallback task profile defaults = %q/%q, want default-model/high", model, effort)
320 }
321 }
322
323 func TestACPSubagentProviderResolverHonorsProfile(t *testing.T) {
324 cfg := config.Default()
325 cfg.Providers = []config.ProviderEntry{
326 {
327 Name: "parent",
328 Kind: acpTestProviderKind,
329 Model: "parent-model",
330 ContextWindow: 111,
331 SupportedEfforts: []string{"low", "high"},
332 },
333 {
334 Name: "sub",
335 Kind: acpTestProviderKind,
336 Models: []string{"sub-model"},
337 Default: "sub-model",
338 ContextWindow: 222,
339 SupportedEfforts: []string{"low", "high"},
340 },
341 }
342 parent, ok := cfg.ResolveModel("parent")
343 if !ok {
344 t.Fatal("parent model did not resolve")
345 }
346
347 resolve := newACPSubagentProviderResolver(cfg, parent, netclient.ProxySpec{})
348 prov, _, ctxWin, err := resolve("sub/sub-model", "HIGH")
349 if err != nil {
350 t.Fatalf("resolve sub profile: %v", err)
351 }
352 got := prov.(*acpTestProvider).cfg
353 if got.Model != "sub-model" || got.Extra["effort"] != "high" || ctxWin != 222 {
354 t.Fatalf("resolved profile = model:%q effort:%v ctx:%d, want sub-model/high/222", got.Model, got.Extra["effort"], ctxWin)
355 }
356
357 prov, _, ctxWin, err = resolve("", "low")
358 if err != nil {
359 t.Fatalf("resolve effort-only profile: %v", err)
360 }
361 got = prov.(*acpTestProvider).cfg
362 if got.Model != "parent-model" || got.Extra["effort"] != "low" || ctxWin != 111 {
363 t.Fatalf("effort-only profile = model:%q effort:%v ctx:%d, want parent-model/low/111", got.Model, got.Extra["effort"], ctxWin)
364 }
365 }
366
367 func TestACPSubagentProviderResolverRejectsInvalidEffort(t *testing.T) {
368 cfg := config.Default()
369 cfg.Providers = []config.ProviderEntry{{
370 Name: "parent",
371 Kind: acpTestProviderKind,
372 Model: "parent-model",
373 SupportedEfforts: []string{"low", "high"},
374 }}
375 parent, ok := cfg.ResolveModel("parent")
376 if !ok {
377 t.Fatal("parent model did not resolve")
378 }
379
380 resolve := newACPSubagentProviderResolver(cfg, parent, netclient.ProxySpec{})
381 if _, _, _, err := resolve("", "max"); err == nil {
382 t.Fatal("invalid effort should fail before ACP task falls back to the parent profile")
383 }
384 }
385
386 func findACPConfigOption(options []acp.SessionConfigOption, id string) (acp.SessionConfigOption, bool) {
387 for _, opt := range options {
388 if opt.ID == id {
389 return opt, true
390 }
391 }
392 return acp.SessionConfigOption{}, false
393 }
394
395 func toolMap(tools []tool.Tool) map[string]tool.Tool {
396 out := make(map[string]tool.Tool, len(tools))
397 for _, t := range tools {
398 out[t.Name()] = t
399 }
400 return out
401 }
402
403 func toolNames(tools map[string]tool.Tool) []string {
404 out := make([]string, 0, len(tools))
405 for name := range tools {
406 out = append(out, name)
407 }
408 return out
409 }
410
411 type acpTestProvider struct {
412 cfg provider.Config
413 }
414
415 func (p *acpTestProvider) Name() string { return p.cfg.Name }
416
417 func (p *acpTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
418 ch := make(chan provider.Chunk, 1)
419 ch <- provider.Chunk{Type: provider.ChunkDone}
420 close(ch)
421 return ch, nil
422 }
423
423 lines GO