| 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, ¶ms) |
| 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 |