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