返回 DeepSeek-Reasonix
acp_extension_test.go
根目录 / internal / cli / acp_extension_test.go
1 package cli
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "reasonix/internal/acp"
17 "reasonix/internal/config"
18 "reasonix/internal/control"
19 "reasonix/internal/event"
20 "reasonix/internal/pluginpkg"
21 "reasonix/internal/provider"
22 )
23
24 // ACP-level coverage for extension-hosted providers: a plugin/... model in
25 // session/new resolves and streams through boot's preflighted sidecar, and a
26 // mid-session switch (RebuildSession) moves to a config model and back.
27
28 const (
29 acpFakeEnvEnable = "REASONIX_ACP_FAKE_SIDECAR"
30 acpFakeEnvPluginName = "REASONIX_ACP_FAKE_PLUGIN_NAME"
31 )
32
33 // TestACPFakeSidecarHelperProcess is the re-exec entry point for the ACP fake
34 // sidecar; it skips in the parent run. Mirrors the boot package's fake
35 // sidecar (which is test-scoped and not importable).
36 func TestACPFakeSidecarHelperProcess(t *testing.T) {
37 if os.Getenv(acpFakeEnvEnable) != "1" {
38 t.Skip("acp fake sidecar helper process")
39 }
40 runACPFakeSidecar(os.Stdin, os.Stdout)
41 os.Exit(0)
42 }
43
44 func runACPFakeSidecar(stdin io.Reader, stdout io.Writer) {
45 out := bufio.NewWriter(stdout)
46 var writeMu sync.Mutex
47 write := func(format string, args ...any) {
48 writeMu.Lock()
49 defer writeMu.Unlock()
50 fmt.Fprintf(out, format+"\n", args...)
51 _ = out.Flush()
52 }
53 pluginName := strings.TrimSpace(os.Getenv(acpFakeEnvPluginName))
54 providerRef := "plugin/" + pluginName + "/fake/x"
55 descriptor := fmt.Sprintf(`{"ref":%q,"displayName":"ACP Fake","model":"x","contextWindow":64000,"tools":true}`, providerRef)
56 initResult := fmt.Sprintf(`{"protocolVersion":"2","name":"acp-fake","version":"1.0.0","stateSchemaVersion":0,"providers":[%s]}`, descriptor)
57
58 streamCompletion := func(id json.RawMessage, rawParams json.RawMessage) {
59 var params struct {
60 StreamID string `json:"streamId"`
61 }
62 _ = json.Unmarshal(rawParams, &params)
63 write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(id))
64 go func() {
65 chunk := func(seq int, body string) {
66 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/chunk","params":{"streamId":%q,"seq":%d,"chunk":%s}}`, params.StreamID, seq, body)
67 }
68 chunk(1, `{"type":"text","text":"acp-fake-hello "}`)
69 chunk(2, `{"type":"text","text":"acp-fake-world"}`)
70 chunk(3, `{"type":"usage","usage":{"promptTokens":5,"completionTokens":7,"totalTokens":12,"cacheHitTokens":2,"cacheMissTokens":3,"reasoningTokens":4,"finishReason":"stop"}}`)
71 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/end","params":{"streamId":%q,"lastSeq":3}}`, params.StreamID)
72 }()
73 }
74
75 in := bufio.NewReader(stdin)
76 for {
77 line, err := in.ReadBytes('\n')
78 if len(line) > 0 {
79 var frame struct {
80 ID json.RawMessage `json:"id"`
81 Method string `json:"method"`
82 Params json.RawMessage `json:"params"`
83 }
84 if json.Unmarshal(line, &frame) == nil && frame.Method != "" {
85 var result string
86 switch frame.Method {
87 case "extension/initialize":
88 result = initResult
89 case "extension/provider/catalog":
90 result = fmt.Sprintf(`{"providers":[%s]}`, descriptor)
91 case "extension/provider/stream/open":
92 streamCompletion(frame.ID, frame.Params)
93 continue
94 case "extension/provider/stream/cancel":
95 result = `{"cancelled":true}`
96 case "extension/shutdown":
97 write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(frame.ID))
98 return
99 default:
100 continue
101 }
102 write(`{"jsonrpc":"2.0","id":%s,"result":%s}`, string(frame.ID), result)
103 }
104 }
105 if err != nil {
106 return
107 }
108 }
109 }
110
111 // installACPFakeProviderPlugin installs the re-executed test binary as an
112 // enabled v1 runtime package declaring one extension provider.
113 func installACPFakeProviderPlugin(t *testing.T, home, name string) {
114 t.Helper()
115 exe, err := os.Executable()
116 if err != nil {
117 t.Fatalf("os.Executable: %v", err)
118 }
119 root := filepath.Join(home, "plugins", name)
120 if err := os.MkdirAll(root, 0o755); err != nil {
121 t.Fatalf("MkdirAll: %v", err)
122 }
123 manifest, err := json.Marshal(map[string]any{
124 "apiVersion": pluginpkg.ManifestAPIVersionV2,
125 "name": name,
126 "version": "1.0.0",
127 "runtime": map[string]any{
128 "command": exe,
129 "args": []string{"-test.run=^TestACPFakeSidecarHelperProcess$"},
130 "capabilities": []string{"providers"},
131 "env": map[string]any{
132 acpFakeEnvEnable: "1",
133 acpFakeEnvPluginName: name,
134 },
135 },
136 })
137 if err != nil {
138 t.Fatalf("marshal manifest: %v", err)
139 }
140 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), manifest, 0o644); err != nil {
141 t.Fatalf("write manifest: %v", err)
142 }
143 if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{
144 Name: name, Root: pluginpkg.RelativeRoot(home, root), Version: "1.0.0", Enabled: true,
145 }); err != nil {
146 t.Fatalf("Upsert: %v", err)
147 }
148 }
149
150 func writeACPFixture(t *testing.T, dir string) {
151 t.Helper()
152 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
153 default_model = "local/fake-model"
154
155 [agent]
156 completion_validation = "off"
157
158 [environment]
159 enabled = false
160
161 [[providers]]
162 name = "local"
163 kind = "acp-test-provider"
164 base_url = "http://example.invalid"
165 model = "fake-model"
166 api_key_env = "REASONIX_TEST_KEY"
167 `), 0o644); err != nil {
168 t.Fatal(err)
169 }
170 }
171
172 func runACPTurnAssistant(t *testing.T, ctrl interface {
173 RunTurn(context.Context, string) error
174 History() []provider.Message
175 }, input string,
176 ) string {
177 t.Helper()
178 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
179 defer cancel()
180 if err := ctrl.RunTurn(ctx, input); err != nil {
181 t.Fatalf("RunTurn(%q): %v", input, err)
182 }
183 var sb strings.Builder
184 for _, m := range ctrl.History() {
185 if m.Role == provider.RoleAssistant {
186 sb.WriteString(m.Content)
187 }
188 }
189 return sb.String()
190 }
191
192 // TestACPSessionWithPluginModelStreamsAndSwitches: a plugin/... model in
193 // session/new resolves through boot's preflighted sidecar (RequireKey stays
194 // on for ACP and is correctly skipped for plugin refs), the turn streams the
195 // extension provider's completion, and RebuildSession moves to the config
196 // model and back to the plugin ref.
197 func TestACPSessionWithPluginModelStreamsAndSwitches(t *testing.T) {
198 isolateCLIConfigHome(t)
199 if _, err := config.SetCredential("REASONIX_TEST_KEY", "test-key"); err != nil {
200 t.Fatalf("SetCredential: %v", err)
201 }
202 project := t.TempDir()
203 writeACPFixture(t, project)
204 name := "acpdemo"
205 ref := "plugin/" + name + "/fake/x"
206 installACPFakeProviderPlugin(t, config.ReasonixHomeDir(), name)
207
208 factory := &acpFactory{}
209
210 // sessionBootOptions carries the plugin ref verbatim into boot.
211 opts, err := factory.sessionBootOptions(acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard})
212 if err != nil {
213 t.Fatalf("sessionBootOptions: %v", err)
214 }
215 if opts.Model != ref {
216 t.Fatalf("sessionBootOptions Model = %q, want %q", opts.Model, ref)
217 }
218
219 // session/new with the plugin model.
220 ctrl, err := factory.NewSession(context.Background(), acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard})
221 if err != nil {
222 t.Fatalf("NewSession with plugin model: %v", err)
223 }
224 if got := ctrl.ModelRef(); got != ref {
225 t.Fatalf("session model ref = %q, want %q", got, ref)
226 }
227 if assistant := runACPTurnAssistant(t, ctrl, "say hi"); !strings.Contains(assistant, "acp-fake-hello acp-fake-world") {
228 t.Fatalf("assistant = %q, want the extension provider's fixed completion", assistant)
229 }
230
231 // Mid-session switch to the config model, then back to the plugin ref.
232 switched, err := factory.RebuildSession(context.Background(), acp.SessionParams{Cwd: project, Model: "local/fake-model", Sink: event.Discard}, ctrl)
233 if err != nil {
234 ctrl.Close()
235 t.Fatalf("RebuildSession to config model: %v", err)
236 }
237 if err := control.ActivateControllerReplacement(ctrl, switched); err != nil {
238 ctrl.Close()
239 switched.ReleaseResources()
240 t.Fatalf("activate config-model replacement: %v", err)
241 }
242 ctrl.ReleaseResources()
243 if got := switched.ModelRef(); got != "local/fake-model" {
244 t.Fatalf("switched model ref = %q, want local/fake-model", got)
245 }
246 back, err := factory.RebuildSession(context.Background(), acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard}, switched)
247 if err != nil {
248 switched.Close()
249 t.Fatalf("RebuildSession back to plugin model: %v", err)
250 }
251 if err := control.ActivateControllerReplacement(switched, back); err != nil {
252 switched.Close()
253 back.ReleaseResources()
254 t.Fatalf("activate plugin-model replacement: %v", err)
255 }
256 switched.ReleaseResources()
257 defer back.Close()
258 if got := back.ModelRef(); got != ref {
259 t.Fatalf("back-switched model ref = %q, want %q", got, ref)
260 }
261 if assistant := runACPTurnAssistant(t, back, "say hi again"); !strings.Contains(assistant, "acp-fake-hello acp-fake-world") {
262 t.Fatalf("switched-back assistant = %q, want the extension provider's fixed completion", assistant)
263 }
264
265 // The config-state surface tolerates the plugin ref too (no unknown-model
266 // error), reporting it as the current model.
267 state, err := factory.SessionConfigState(context.Background(), acp.SessionConfigStateParams{Cwd: project, Model: ref})
268 if err != nil {
269 t.Fatalf("SessionConfigState with plugin model: %v", err)
270 }
271 if state.Model != ref || state.Models == nil || state.Models.CurrentModelID != ref {
272 t.Fatalf("SessionConfigState model = %q / %+v, want %q", state.Model, state.Models, ref)
273 }
274
275 switched.Close()
276 ctrl.Close()
277 }
278
278 lines GO