Files
mikyan fe59262188 feat(agent): add Huawei Cloud CodeArts support (#6985)
* feat(agent): add Huawei Cloud CodeArts support

* fix(migrations): resolve CodeArts prefix collision

* fix(agent): address CodeArts review feedback

* fix(agent): tighten CodeArts integration scope

* fix(agent): resolve CodeArts follow-up review blockers

* fix(agent): redact CodeArts launch arguments

* docs: sync supported runtime counts

* fix(agent): own CodeArts model probe process tree
2026-08-31 12:30:59 +08:00

278 lines
8.8 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCodeArtsProtocolFamilyDispatchesToIndependentBackend(t *testing.T) {
if !IsSupportedType("codearts") {
t.Fatal("codearts must be a supported protocol family")
}
if IsBuiltinRuntime("codearts") {
t.Fatal("codearts must not be registered as a built-in runtime identity")
}
backend, err := New("codearts", Config{})
if err != nil {
t.Fatal(err)
}
codearts, ok := backend.(*codeartsBackend)
if !ok {
t.Fatalf("New(codearts) = %T, want *codeartsBackend", backend)
}
if _, shared := backend.(*opencodeBackend); shared {
t.Fatal("CodeArts unexpectedly resolved to the OpenCode backend")
}
if codearts.cfg.Logger == nil {
t.Fatal("CodeArts backend did not receive its config")
}
}
func TestCodeArtsExecuteUsesNativeRunFlagsAndStdin(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
argvPath := filepath.Join(dir, "argv.txt")
stdinPath := filepath.Join(dir, "stdin.txt")
prompt := "first line\nsecond line"
backend, err := ResolveBackend("codearts", Config{
ExecutablePath: self,
LaunchPrefix: []string{
"wrapper", "--format", "text", "--auto", "--dir", "wrong-root",
},
Logger: slog.Default(),
Env: map[string]string{
opencodeStdinHelperEnv: "1",
opencodeStdinHelperArgvFile: argvPath,
opencodeStdinHelperInFile: stdinPath,
},
})
if err != nil {
t.Fatal(err)
}
session, err := backend.Execute(t.Context(), prompt, ExecOptions{
Cwd: dir,
Timeout: 30 * time.Second,
Model: "huaweicloud-maas/deepseek-v3.2",
ThinkingLevel: "high",
ResumeSessionID: "ses_existing",
CustomArgs: []string{
"--dangerously-skip-permissions", "--auto", "--sandbox",
"--dir", "wrong", "--variant", "high", "--title", "Multica task",
},
})
if err != nil {
t.Fatal(err)
}
go func() {
for range session.Messages {
}
}()
result := <-session.Result
if result.Status != "completed" || result.Output != "ok" || result.SessionID != "ses_fake" {
t.Fatalf("result = %+v", result)
}
argvRaw, err := os.ReadFile(argvPath)
if err != nil {
t.Fatal(err)
}
args := strings.Split(string(argvRaw), "\n")
want := []string{"wrapper", "run", "--format", "json", "--auto", "--model", "huaweicloud-maas/deepseek-v3.2", "--session", "ses_existing", "--title", "Multica task"}
if strings.Join(args, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("argv = %#v, want %#v", args, want)
}
stdinRaw, err := os.ReadFile(stdinPath)
if err != nil {
t.Fatal(err)
}
if string(stdinRaw) != prompt {
t.Fatalf("stdin = %q, want %q", stdinRaw, prompt)
}
}
func TestCodeArtsRejectsNonJSONSuccessOutput(t *testing.T) {
backend := &codeartsBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 8)
result := backend.processEvents(strings.NewReader("\x1b[31mError: CODEARTS_CLI_AK is not configured\x1b[0m\n"), ch)
if result.status != "failed" {
t.Fatalf("status = %q, want failed", result.status)
}
if !strings.Contains(result.errMsg, "codearts returned no parseable JSON events") ||
!strings.Contains(result.errMsg, "CODEARTS_CLI_AK is not configured") {
t.Fatalf("error = %q", result.errMsg)
}
if strings.ContainsRune(result.errMsg, '\x1b') {
t.Fatalf("ANSI escape leaked into error: %q", result.errMsg)
}
}
func TestCodeArtsStreamErrorsUseCodeArtsLabel(t *testing.T) {
backend := &codeartsBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 8)
result := backend.processEvents(strings.NewReader(`{"type":"step_start","sessionID":"ses","part":{}}`+"\n"), ch)
if result.status != "failed" || !strings.HasPrefix(result.errMsg, "codearts stream ended") {
t.Fatalf("result = %+v", result)
}
}
func TestCodeArtsEnvironmentMatchesLauncherAndAllowsOverrides(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
oldTLS, hadTLS := os.LookupEnv("NODE_TLS_REJECT_UNAUTHORIZED")
if err := os.Unsetenv("NODE_TLS_REJECT_UNAUTHORIZED"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if hadTLS {
_ = os.Setenv("NODE_TLS_REJECT_UNAUTHORIZED", oldTLS)
} else {
_ = os.Unsetenv("NODE_TLS_REJECT_UNAUTHORIZED")
}
})
env := buildCodeArtsEnv(map[string]string{"PLUGIN_ENV": "custom"})
values := map[string]string{}
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if ok {
values[key] = value
}
}
if values["SCENARIO"] != "codeartsdoer" || values["PLUGIN_ENV"] != "custom" {
t.Fatalf("unexpected CodeArts env: SCENARIO=%q PLUGIN_ENV=%q", values["SCENARIO"], values["PLUGIN_ENV"])
}
wantConfig := filepath.Join(home, ".codeartsdoer", "codearts_cli.json")
if values["OPENCODE_CONFIG"] != wantConfig {
t.Fatalf("OPENCODE_CONFIG = %q, want %q", values["OPENCODE_CONFIG"], wantConfig)
}
if _, ok := values["NODE_TLS_REJECT_UNAUTHORIZED"]; ok {
t.Fatal("CodeArts launcher must not disable TLS verification by default")
}
// Disabling certificate verification remains possible only as an explicit
// per-agent custom_env opt-in; the launcher must never do it by default.
insecureValues := envValues(buildCodeArtsEnv(map[string]string{"NODE_TLS_REJECT_UNAUTHORIZED": "0"}))
if insecureValues["NODE_TLS_REJECT_UNAUTHORIZED"] != "0" {
t.Fatalf("explicit NODE_TLS_REJECT_UNAUTHORIZED override was not preserved: %q", insecureValues["NODE_TLS_REJECT_UNAUTHORIZED"])
}
}
func TestDiscoverCodeArtsModels(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv(codeartsModelHelperEnv, "1")
argvPath := filepath.Join(t.TempDir(), "model-argv.txt")
t.Setenv(codeartsModelHelperArgvFile, argvPath)
models, err := discoverCodeArtsModels(context.Background(), Command{Path: self, Prefix: []string{"wrapper"}})
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "huaweicloud-maas/deepseek-v3.2" || models[0].Provider != "huaweicloud-maas" {
t.Fatalf("models = %+v", models)
}
if models[0].Thinking != nil {
t.Fatalf("CodeArts model must not advertise OpenCode variants: %+v", models[0])
}
argvRaw, err := os.ReadFile(argvPath)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Split(string(argvRaw), "\n"), []string{"wrapper", "models", "--verbose"}; strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("model discovery argv = %#v, want %#v", got, want)
}
}
func TestDetectVersionUsesCodeArtsCommandPrefix(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv(codeartsModelHelperEnv, "1")
argvPath := filepath.Join(t.TempDir(), "version-argv.txt")
t.Setenv(codeartsModelHelperArgvFile, argvPath)
version, err := DetectVersion(context.Background(), Command{Path: self, Prefix: []string{"wrapper"}})
if err != nil {
t.Fatal(err)
}
if version != "CodeArts Agent 1.2.3" {
t.Fatalf("version = %q, want CodeArts Agent 1.2.3", version)
}
argvRaw, err := os.ReadFile(argvPath)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Split(string(argvRaw), "\n"), []string{"wrapper", "--version"}; strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("version argv = %#v, want %#v", got, want)
}
}
func TestCodeArtsListModelsCacheSeparatesCommandPrefixes(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv(codeartsModelHelperEnv, "1")
commands := []Command{
{Path: self, Prefix: []string{"profile-a"}},
{Path: self, Prefix: []string{"profile-b"}},
}
modelCacheMu.Lock()
for _, command := range commands {
delete(modelCache, discoveryCacheKey("codearts", command))
}
modelCacheMu.Unlock()
t.Cleanup(func() {
modelCacheMu.Lock()
defer modelCacheMu.Unlock()
for _, command := range commands {
delete(modelCache, discoveryCacheKey("codearts", command))
}
})
for i, command := range commands {
catalog, err := ListModels(context.Background(), "codearts", command)
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("test/profile-%c", 'a'+rune(i))
if len(catalog.Models) != 1 || catalog.Models[0].ID != want {
t.Fatalf("prefix %q catalog = %+v, want model %q", command.Prefix[0], catalog, want)
}
}
}
func envValues(env []string) map[string]string {
values := make(map[string]string, len(env))
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if ok {
values[key] = value
}
}
return values
}
func TestResolveCodeArtsNativeFromShim(t *testing.T) {
shim := filepath.Join("C:\\", "Users", "test", ".codeartsdoer", "installers", "codearts.cmd")
native := filepath.Join(filepath.Dir(shim), "bin", "codearts.exe")
if got := resolveCodeArtsNativeFromShim(shim, fakeStat(native)); got != native {
t.Fatalf("got %q, want %q", got, native)
}
if got := resolveCodeArtsNativeFromShim(filepath.Join(filepath.Dir(shim), "other.cmd"), fakeStat(native)); got != "" {
t.Fatalf("non-CodeArts shim resolved to %q", got)
}
}