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