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