返回 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 approveWorkspace(t, dir)
171 }
172
173 func runACPTurnAssistant(t *testing.T, ctrl interface {
174 RunTurn(context.Context, string) error
175 History() []provider.Message
176 }, input string,
177 ) string {
178 t.Helper()
179 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
180 defer cancel()
181 if err := ctrl.RunTurn(ctx, input); err != nil {
182 t.Fatalf("RunTurn(%q): %v", input, err)
183 }
184 var sb strings.Builder
185 for _, m := range ctrl.History() {
186 if m.Role == provider.RoleAssistant {
187 sb.WriteString(m.Content)
188 }
189 }
190 return sb.String()
191 }
192
193 // TestACPSessionWithPluginModelStreamsAndSwitches: a plugin/... model in
194 // session/new resolves through boot's preflighted sidecar (RequireKey stays
195 // on for ACP and is correctly skipped for plugin refs), the turn streams the
196 // extension provider's completion, and RebuildSession moves to the config
197 // model and back to the plugin ref.
198 func TestACPSessionWithPluginModelStreamsAndSwitches(t *testing.T) {
199 isolateCLIConfigHome(t)
200 if _, err := config.SetCredential("REASONIX_TEST_KEY", "test-key"); err != nil {
201 t.Fatalf("SetCredential: %v", err)
202 }
203 project := t.TempDir()
204 writeACPFixture(t, project)
205 name := "acpdemo"
206 ref := "plugin/" + name + "/fake/x"
207 installACPFakeProviderPlugin(t, config.ReasonixHomeDir(), name)
208
209 factory := &acpFactory{}
210
211 // sessionBootOptions carries the plugin ref verbatim into boot.
212 opts, err := factory.sessionBootOptions(acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard})
213 if err != nil {
214 t.Fatalf("sessionBootOptions: %v", err)
215 }
216 if opts.Model != ref {
217 t.Fatalf("sessionBootOptions Model = %q, want %q", opts.Model, ref)
218 }
219
220 // session/new with the plugin model.
221 ctrl, err := factory.NewSession(context.Background(), acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard})
222 if err != nil {
223 t.Fatalf("NewSession with plugin model: %v", err)
224 }
225 if got := ctrl.ModelRef(); got != ref {
226 t.Fatalf("session model ref = %q, want %q", got, ref)
227 }
228 if assistant := runACPTurnAssistant(t, ctrl, "say hi"); !strings.Contains(assistant, "acp-fake-hello acp-fake-world") {
229 t.Fatalf("assistant = %q, want the extension provider's fixed completion", assistant)
230 }
231
232 // Mid-session switch to the config model, then back to the plugin ref.
233 switched, err := factory.RebuildSession(context.Background(), acp.SessionParams{Cwd: project, Model: "local/fake-model", Sink: event.Discard}, ctrl)
234 if err != nil {
235 ctrl.Close()
236 t.Fatalf("RebuildSession to config model: %v", err)
237 }
238 if err := control.ActivateControllerReplacement(ctrl, switched); err != nil {
239 ctrl.Close()
240 switched.ReleaseResources()
241 t.Fatalf("activate config-model replacement: %v", err)
242 }
243 ctrl.ReleaseResources()
244 if got := switched.ModelRef(); got != "local/fake-model" {
245 t.Fatalf("switched model ref = %q, want local/fake-model", got)
246 }
247 back, err := factory.RebuildSession(context.Background(), acp.SessionParams{Cwd: project, Model: ref, Sink: event.Discard}, switched)
248 if err != nil {
249 switched.Close()
250 t.Fatalf("RebuildSession back to plugin model: %v", err)
251 }
252 if err := control.ActivateControllerReplacement(switched, back); err != nil {
253 switched.Close()
254 back.ReleaseResources()
255 t.Fatalf("activate plugin-model replacement: %v", err)
256 }
257 switched.ReleaseResources()
258 defer back.Close()
259 if got := back.ModelRef(); got != ref {
260 t.Fatalf("back-switched model ref = %q, want %q", got, ref)
261 }
262 if assistant := runACPTurnAssistant(t, back, "say hi again"); !strings.Contains(assistant, "acp-fake-hello acp-fake-world") {
263 t.Fatalf("switched-back assistant = %q, want the extension provider's fixed completion", assistant)
264 }
265
266 // The config-state surface tolerates the plugin ref too (no unknown-model
267 // error), reporting it as the current model.
268 state, err := factory.SessionConfigState(context.Background(), acp.SessionConfigStateParams{Cwd: project, Model: ref})
269 if err != nil {
270 t.Fatalf("SessionConfigState with plugin model: %v", err)
271 }
272 if state.Model != ref || state.Models == nil || state.Models.CurrentModelID != ref {
273 t.Fatalf("SessionConfigState model = %q / %+v, want %q", state.Model, state.Models, ref)
274 }
275
276 switched.Close()
277 ctrl.Close()
278 }
279
279 lines GO