mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
* feat(agent): discover codebuddy models over ACP instead of scraping --help (MUL-5549) CodeBuddy speaks ACP, and `session/new` answers with a structured catalog under models.availableModels plus a currentModelId — exactly the shape the shared parseACPSessionNewModels already reads for Copilot / Kimi / Kiro / Qoder / Grok / TRAE. Scraping the `--model` line out of `codebuddy --help` was never necessary. The help text carried IDs and nothing else, which cost us three things: - Labels were guessed from the ID and were simply wrong. `kimi-k3-1` rendered as "Kimi K3 1" where the CLI says Kimi-K3; `deepseek-v3-2-volc` as "Deepseek V3 2 Volc" where the CLI says DeepSeek-V3.2. - The default model was a "first entry wins" guess rather than the advertised currentModelId. - The effort catalog needed a second regex over the same output. All three come from the handshake now. The effort catalog rides along in the same session/new response as the `thought_level` config option, so it costs no extra process — which also retires the "at most one --help per request" constraint added in #6196, because --help is no longer run at all. One trap worth naming: thought_level advertises `enabled` ("On (default)") alongside the six real levels, but `--effort enabled` is not a valid command line — the daemon passes the selected level straight to the flag. Advertised levels are filtered against the flag's accepted set, and a currentValue outside that set (the default `enabled`) becomes an empty DefaultLevel, which the UI renders as a generic "Default" instead of a value we cannot pass through. Two adjacent inaccuracies surfaced while confirming the real level set against CodeBuddy 2.130.0, both fixed here: the static effort fallback omitted `minimal` and `max`, and so did the server-side IsKnownThinkingValue gate — so the server rejected two levels the CLI genuinely accepts. Discovery keeps its fallback, still marked Fallback so it can never be cached as authoritative (#6196). That covers the not-logged-in case, which is deliberately NOT special-cased with an auth step: the catalog came back without calling authenticate on a logged-in CLI, and inventing an auth branch we cannot exercise would be speculation. Removes codebuddyModelRe, parseCodebuddyModels, codebuddyModelLabel, codebuddyModelProvider, codebuddyEffortRe, parseCodebuddyEffortHelp, codebuddyEffortSuperset, codebuddyHelpOutput and its 60s help cache. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): keep codebuddy's vendor grouping after the ACP migration (MUL-5549) Review nit, and a real regression in the previous commit. Dropping codebuddyModelProvider looked like removing dead code, but it was the only thing populating Model.Provider for CodeBuddy — and the picker groups on that field. acpModelEntry can only recover a vendor from a `vendor:model` id. CodeBuddy's are bare (`glm-5.2`, `kimi-k3-1`), so every model came back with an empty Provider, and model-dropdown renders the empty group with no header at all: all 16 models would have collapsed into one unlabelled list where main shows Zhipu / Kimi / MiniMax / DeepSeek / Hunyuan sections. Restores the prefix inference as a post-pass over the ACP catalog, exactly the shape discoverCopilotModels already uses for the same reason. Verified against the real CLI: all 16 models land in five vendor groups with none ungrouped. Tests assert the vendor for every id CodeBuddy 2.130.0 advertises plus the static fallback ids, and that the fallback entries' hardcoded providers agree with the inference. Removing the post-pass fails them. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
321 lines
12 KiB
Go
321 lines
12 KiB
Go
//go:build !windows
|
|
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// codebuddyACPSessionResult is the shape CodeBuddy 2.130.0 actually returns from
|
|
// `session/new` over `codebuddy --acp`, trimmed to four models. Captured from the
|
|
// real CLI: the catalog lives under models.availableModels with an advertised
|
|
// currentModelId, and the effort catalog rides along in configOptions as
|
|
// thought_level — including the `enabled` choice that is a session toggle rather
|
|
// than a valid `--effort` argument.
|
|
const codebuddyACPSessionResult = `{"sessionId":"ses-codebuddy","models":{"currentModelId":"hy3",` +
|
|
`"availableModels":[` +
|
|
`{"modelId":"hy3","name":"Hy3","description":"x0.00 credits"},` +
|
|
`{"modelId":"glm-5.2","name":"GLM-5.2","description":"x0.79 credits"},` +
|
|
`{"modelId":"kimi-k3-1","name":"Kimi-K3","description":"x1.62 credits"},` +
|
|
`{"modelId":"deepseek-v3-2-volc","name":"DeepSeek-V3.2","description":"x0.29 credits"}]},` +
|
|
`"configOptions":[` +
|
|
`{"type":"select","id":"mode","name":"Permission Mode","currentValue":"default","options":[{"value":"default","name":"Default"}]},` +
|
|
`{"type":"select","id":"thought_level","name":"Deep Thinking","category":"thought_level","currentValue":"enabled","options":[` +
|
|
`{"value":"minimal","name":"Minimal"},{"value":"low","name":"Low"},{"value":"medium","name":"Medium"},` +
|
|
`{"value":"high","name":"High"},{"value":"xhigh","name":"X-High"},{"value":"max","name":"Max"},` +
|
|
`{"value":"enabled","name":"On (default)"}]}]}`
|
|
|
|
// writeCodebuddyACPStub writes an executable stub that speaks just enough ACP for
|
|
// discovery. sessionResult is returned from session/new; when it is empty the
|
|
// stub fails that call, which is how a not-logged-in CLI is simulated.
|
|
func writeCodebuddyACPStub(t *testing.T, sessionResult string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "codebuddy")
|
|
sessionReply := `printf '{"jsonrpc":"2.0","id":%s,"result":` + sessionResult + `}\n' "$id"`
|
|
if sessionResult == "" {
|
|
sessionReply = `printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32000,"message":"not authenticated"}}\n' "$id"`
|
|
}
|
|
script := `#!/bin/sh
|
|
# Minimal CodeBuddy ACP stub: initialize + session/new only, which is all model
|
|
# discovery drives. --version answers so the runtime-registration probe is happy.
|
|
case "$1" in
|
|
--version) echo '2.130.0'; exit 0 ;;
|
|
esac
|
|
while IFS= read -r line; do
|
|
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
|
|
case "$line" in
|
|
*'"method":"initialize"'*)
|
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[{"id":"external","name":"Login with Google/Github"}]}}\n' "$id"
|
|
;;
|
|
*'"method":"session/new"'*)
|
|
` + sessionReply + `
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
`
|
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write codebuddy ACP stub: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func resetCodebuddyDiscoveryCaches(t *testing.T) {
|
|
t.Helper()
|
|
clear := func() {
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, "codebuddy")
|
|
modelCacheMu.Unlock()
|
|
resetThinkingCacheForTests()
|
|
}
|
|
clear()
|
|
t.Cleanup(clear)
|
|
}
|
|
|
|
// TestDiscoverCodebuddyModelsFromACP is the core of the migration (MUL-5549):
|
|
// the catalog now comes from the ACP handshake, so IDs, display names AND the
|
|
// default model all come from CodeBuddy instead of being guessed from the ID.
|
|
func TestDiscoverCodebuddyModelsFromACP(t *testing.T) {
|
|
resetCodebuddyDiscoveryCaches(t)
|
|
path := writeCodebuddyACPStub(t, codebuddyACPSessionResult)
|
|
|
|
catalog, err := discoverCodebuddyModels(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("discoverCodebuddyModels: %v", err)
|
|
}
|
|
if catalog.Fallback {
|
|
t.Fatal("a successful ACP handshake must not be marked Fallback")
|
|
}
|
|
if len(catalog.Models) != 4 {
|
|
t.Fatalf("expected 4 models, got %d: %+v", len(catalog.Models), catalog.Models)
|
|
}
|
|
|
|
// Labels are the CLI's own names. The old --help path had to derive these
|
|
// from the ID and got them wrong: kimi-k3-1 became "Kimi K3 1" and
|
|
// deepseek-v3-2-volc became "Deepseek V3 2 Volc".
|
|
wantLabel := map[string]string{
|
|
"hy3": "Hy3",
|
|
"glm-5.2": "GLM-5.2",
|
|
"kimi-k3-1": "Kimi-K3",
|
|
"deepseek-v3-2-volc": "DeepSeek-V3.2",
|
|
}
|
|
for _, m := range catalog.Models {
|
|
if want, ok := wantLabel[m.ID]; !ok {
|
|
t.Errorf("unexpected model %q", m.ID)
|
|
} else if m.Label != want {
|
|
t.Errorf("label(%q) = %q, want the CLI's own %q", m.ID, m.Label, want)
|
|
}
|
|
}
|
|
|
|
// The default is the advertised currentModelId, not "whichever came first".
|
|
var defaults []string
|
|
for _, m := range catalog.Models {
|
|
if m.Default {
|
|
defaults = append(defaults, m.ID)
|
|
}
|
|
}
|
|
if len(defaults) != 1 || defaults[0] != "hy3" {
|
|
t.Errorf("default models = %v, want exactly [hy3] from currentModelId", defaults)
|
|
}
|
|
|
|
// The ACP payload has no vendor field and CodeBuddy's ids are bare, so
|
|
// without the prefix post-pass every model would land in one unlabelled
|
|
// group instead of the picker's per-vendor sections.
|
|
wantProvider := map[string]string{
|
|
"hy3": "hunyuan",
|
|
"glm-5.2": "zhipu",
|
|
"kimi-k3-1": "kimi",
|
|
"deepseek-v3-2-volc": "deepseek",
|
|
}
|
|
for _, m := range catalog.Models {
|
|
if want := wantProvider[m.ID]; m.Provider != want {
|
|
t.Errorf("provider(%q) = %q, want %q — the picker groups on this", m.ID, m.Provider, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCodebuddyModelProviderCoversRealCatalog pins vendor inference against every
|
|
// ID shape CodeBuddy 2.130.0 actually advertises. A miss here is invisible in the
|
|
// backend but collapses the picker into one unlabelled list.
|
|
func TestCodebuddyModelProviderCoversRealCatalog(t *testing.T) {
|
|
t.Parallel()
|
|
for id, want := range map[string]string{
|
|
// Live ACP catalog, all 16 ids.
|
|
"hy3": "hunyuan", "glm-5.2": "zhipu", "glm-5.1": "zhipu", "glm-5.0": "zhipu",
|
|
"glm-5.0-turbo": "zhipu", "glm-5v-turbo": "zhipu", "glm-4.7": "zhipu",
|
|
"minimax-m3": "minimax", "minimax-m2.7": "minimax",
|
|
"kimi-k3-1": "kimi", "kimi-k2.7": "kimi", "kimi-k2.6": "kimi", "kimi-k2.5": "kimi",
|
|
"deepseek-v4-pro": "deepseek", "deepseek-v4-flash": "deepseek", "deepseek-v3-2-volc": "deepseek",
|
|
// Static fallback ids, which must group too.
|
|
"claude-sonnet-4.6": "anthropic", "claude-opus-4.7": "anthropic",
|
|
"gemini-3.1-pro": "google", "gpt-5.5": "openai",
|
|
"deepseek-v3-2-volc-ioa": "deepseek",
|
|
} {
|
|
if got := codebuddyModelProvider(id); got != want {
|
|
t.Errorf("codebuddyModelProvider(%q) = %q, want %q", id, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCodebuddyStaticModelsCarryProviders guards the fallback path's grouping:
|
|
// those entries hardcode Provider rather than going through the post-pass, so a
|
|
// new entry added without one would silently break grouping there instead.
|
|
func TestCodebuddyStaticModelsCarryProviders(t *testing.T) {
|
|
t.Parallel()
|
|
for _, m := range codebuddyStaticModels() {
|
|
if m.Provider == "" {
|
|
t.Errorf("static fallback model %q has no Provider; the picker would not group it", m.ID)
|
|
}
|
|
if want := codebuddyModelProvider(m.ID); m.Provider != want {
|
|
t.Errorf("static fallback %q has Provider %q but prefix inference says %q", m.ID, m.Provider, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCodebuddyModelsACPEffort pins the effort catalog coming out of the
|
|
// same handshake — no second process — and the `enabled` filtering.
|
|
func TestDiscoverCodebuddyModelsACPEffort(t *testing.T) {
|
|
resetCodebuddyDiscoveryCaches(t)
|
|
path := writeCodebuddyACPStub(t, codebuddyACPSessionResult)
|
|
|
|
catalog, err := discoverCodebuddyModels(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("discoverCodebuddyModels: %v", err)
|
|
}
|
|
thinking := catalog.Models[0].Thinking
|
|
if thinking == nil {
|
|
t.Fatal("expected the effort catalog to be annotated from the same session/new response")
|
|
}
|
|
var got []string
|
|
for _, lvl := range thinking.SupportedLevels {
|
|
got = append(got, lvl.Value)
|
|
}
|
|
want := []string{"minimal", "low", "medium", "high", "xhigh", "max"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Errorf("levels = %v, want %v", got, want)
|
|
}
|
|
// `enabled` is advertised by ACP but `--effort enabled` is not a valid
|
|
// command line, so it must not reach the picker...
|
|
for _, lvl := range thinking.SupportedLevels {
|
|
if lvl.Value == "enabled" {
|
|
t.Error("`enabled` is a session toggle, not an --effort value; it must be filtered out")
|
|
}
|
|
}
|
|
// ...and since it is the advertised currentValue, DefaultLevel must stay
|
|
// empty ("runtime decides") rather than echo a value we cannot pass.
|
|
if thinking.DefaultLevel != "" {
|
|
t.Errorf("DefaultLevel = %q, want empty because currentValue was the unusable `enabled`", thinking.DefaultLevel)
|
|
}
|
|
}
|
|
|
|
// TestParseACPCodebuddyEffortDefault covers the other branch: when CodeBuddy
|
|
// advertises a real level as current, we echo it.
|
|
func TestParseACPCodebuddyEffortDefault(t *testing.T) {
|
|
raw := json.RawMessage(`{"configOptions":[{"id":"thought_level","currentValue":"high","options":[` +
|
|
`{"value":"low"},{"value":"high"},{"value":"enabled"}]}]}`)
|
|
levels, def := parseACPCodebuddyEffort(raw)
|
|
if strings.Join(levels, ",") != "low,high" {
|
|
t.Errorf("levels = %v, want [low high]", levels)
|
|
}
|
|
if def != "high" {
|
|
t.Errorf("DefaultLevel = %q, want high", def)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCodebuddyModelsFallsBackOnACPFailure covers the not-logged-in /
|
|
// unreachable-CLI cases. The stand-in is still offered so the picker stays
|
|
// usable, but it must be marked Fallback so the server can never cache it as
|
|
// this runtime's real catalog (MUL-5549), and the effort picker must still work.
|
|
func TestDiscoverCodebuddyModelsFallsBackOnACPFailure(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
path func(t *testing.T) string
|
|
}{
|
|
{
|
|
name: "binary missing",
|
|
path: func(t *testing.T) string { return missingAgentExecutable(t, "codebuddy") },
|
|
},
|
|
{
|
|
name: "session/new refused (not logged in)",
|
|
path: func(t *testing.T) string { return writeCodebuddyACPStub(t, "") },
|
|
},
|
|
{
|
|
name: "session/new carries no catalog",
|
|
path: func(t *testing.T) string { return writeCodebuddyACPStub(t, `{"sessionId":"ses-empty"}`) },
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resetCodebuddyDiscoveryCaches(t)
|
|
catalog, err := discoverCodebuddyModels(context.Background(), tc.path(t))
|
|
if err != nil {
|
|
t.Fatalf("discoverCodebuddyModels: %v", err)
|
|
}
|
|
if !catalog.Fallback {
|
|
t.Error("a degraded discovery must be marked Fallback")
|
|
}
|
|
if len(catalog.Models) == 0 {
|
|
t.Error("expected the static stand-in to still be offered to the UI")
|
|
}
|
|
if catalog.Models[0].Thinking == nil || len(catalog.Models[0].Thinking.SupportedLevels) == 0 {
|
|
t.Error("the fallback path must still annotate effort levels")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCodebuddyStaticEffortFallbackCoversFlagValues keeps the offline fallback
|
|
// honest against the flag it feeds: every level offered must be one `--effort`
|
|
// accepts, and the set should not silently shrink below what the CLI supports.
|
|
func TestCodebuddyStaticEffortFallbackCoversFlagValues(t *testing.T) {
|
|
t.Parallel()
|
|
for _, level := range codebuddyStaticEffortFallback {
|
|
if !codebuddyFlagEffortValues[level] {
|
|
t.Errorf("static fallback offers %q, which `--effort` does not accept", level)
|
|
}
|
|
if !IsKnownThinkingValue("codebuddy", level) {
|
|
t.Errorf("static fallback offers %q, which the server-side gate rejects", level)
|
|
}
|
|
}
|
|
if len(codebuddyStaticEffortFallback) != len(codebuddyFlagEffortValues) {
|
|
t.Errorf("static fallback has %d levels but --effort accepts %d; they drifted apart",
|
|
len(codebuddyStaticEffortFallback), len(codebuddyFlagEffortValues))
|
|
}
|
|
}
|
|
|
|
// TestCachedDiscoveryDoesNotCacheFallback pins that a fallback never occupies
|
|
// the daemon's 60s discovery cache: the next request must be free to retry.
|
|
func TestCachedDiscoveryDoesNotCacheFallback(t *testing.T) {
|
|
const key = "test-cache-fallback"
|
|
reset := func() {
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, key)
|
|
modelCacheMu.Unlock()
|
|
}
|
|
reset()
|
|
t.Cleanup(reset)
|
|
|
|
calls := 0
|
|
fn := func() (Catalog, error) {
|
|
calls++
|
|
return Catalog{Models: []Model{{ID: "stand-in"}}, Fallback: true}, nil
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
got, err := cachedDiscovery(key, fn)
|
|
if err != nil {
|
|
t.Fatalf("cachedDiscovery: %v", err)
|
|
}
|
|
if !got.Fallback {
|
|
t.Error("cachedDiscovery must preserve the Fallback marker")
|
|
}
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("a fallback must not be cached: expected fn called 2x, got %d", calls)
|
|
}
|
|
}
|