Files
dingtalk-workspace-cli/internal/helpers/connect_provider_model_test.go
修雨 b826440995 fix(connect): update provider-model test for forwarderForChannel clientID arg
PR #15's connect_provider_model_test.go calls forwarderForChannel with the
old 2-arg signature; PR #16 added a clientID parameter. Both branches passed
in isolation but the merged release broke 'go test ./internal/helpers/'.
Pass an empty clientID (the test exercises model-arg injection, unrelated to
session persistence).
2026-06-13 23:03:12 +08:00

176 lines
6.8 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// writeClaudeSettings drops a $CLAUDE_CONFIG_DIR/settings.json whose env block
// carries a custom provider base URL, mirroring how cc-switch and similar tools
// store third-party Anthropic-compatible credentials (issue #10 / #14).
func writeClaudeSettings(t *testing.T, baseURL string) string {
t.Helper()
dir := t.TempDir()
body := `{"env":{"ANTHROPIC_BASE_URL":"` + baseURL + `","ANTHROPIC_AUTH_TOKEN":"sk-test"}}`
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(body), 0o644); err != nil {
t.Fatalf("write settings: %v", err)
}
return dir
}
// TestForwarderProviderBaseURLDropsHaiku is the issue #14 root-cause fix: when a
// custom provider base URL is injected and the user did not pick a model, the
// built-in --model claude-haiku-... pin must be dropped (the provider may not
// map it, returning a 422). An explicit --agent-model still wins, and an
// official-login user (no base URL) keeps the pin.
func TestForwarderProviderBaseURLDropsHaiku(t *testing.T) {
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
t.Setenv("DWS_AGENT_CMD", "")
t.Setenv("DWS_AGENT_MODEL", "")
// Make sure the connector's own env does not carry a base URL for the
// no-provider subtest.
t.Setenv("ANTHROPIC_BASE_URL", "")
os.Unsetenv("ANTHROPIC_BASE_URL")
stub := t.TempDir()
if err := writeExecStub(stub, "claude"); err != nil {
t.Fatalf("stub claude: %v", err)
}
t.Setenv("PATH", stub)
argvOf := func(fwd forwarder) string {
ef, ok := fwd.(*execForwarder)
if !ok {
t.Fatalf("not an execForwarder: %T", fwd)
}
return strings.Join(ef.argv, " ")
}
streamArgvOf := func(fwd forwarder) string {
ef := fwd.(*execForwarder)
return strings.Join(ef.streamArgv, " ")
}
// 1) Provider base URL injected via Claude settings, no --agent-model:
// haiku pin dropped from both argv and streamArgv.
cfg := writeClaudeSettings(t, "https://my-proxy.example.com")
t.Setenv("CLAUDE_CONFIG_DIR", cfg)
fwd, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: true})
if err != nil {
t.Fatalf("forwarder (provider): %v", err)
}
if a := argvOf(fwd); strings.Contains(a, "--model") || strings.Contains(a, "haiku") {
t.Fatalf("provider+no-model: argv should drop haiku pin, got: %s", a)
}
if a := streamArgvOf(fwd); strings.Contains(a, "--model") || strings.Contains(a, "haiku") {
t.Fatalf("provider+no-model: streamArgv should drop haiku pin, got: %s", a)
}
// 2) Provider base URL injected but user picked a model: user's model wins.
fwd2, err := forwarderForChannel("claudecode", "", connectAgentOptions{Model: "my-custom-model"})
if err != nil {
t.Fatalf("forwarder (provider+model): %v", err)
}
if a := argvOf(fwd2); !strings.Contains(a, "--model my-custom-model") {
t.Fatalf("provider+model: argv should carry user model, got: %s", a)
}
if a := argvOf(fwd2); strings.Contains(a, "haiku") {
t.Fatalf("provider+model: haiku pin should be replaced, got: %s", a)
}
// 3) No provider (official login): built-in haiku pin kept.
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) // empty dir -> no settings.json
fwd3, err := forwarderForChannel("claudecode", "", connectAgentOptions{})
if err != nil {
t.Fatalf("forwarder (official): %v", err)
}
if a := argvOf(fwd3); !strings.Contains(a, "--model claude-haiku-4-5-20251001") {
t.Fatalf("official login: built-in haiku pin must be kept, got: %s", a)
}
}
// TestForwardReturnsHintOnAPIError covers the issue #14 anti-echo guard: an
// agent that prints "API Error: 422 ..." to stdout (and exits 0) must NOT have
// that raw error forwarded as the answer; forward returns an actionable hint.
func TestForwardReturnsHintOnAPIError(t *testing.T) {
stub := t.TempDir()
bin := filepath.Join(stub, "fakeagent")
// Print a provider 422 to stdout and exit 0, exactly like claude does when
// the upstream provider rejects the model.
script := "#!/bin/sh\necho 'API Error: 422 未找到匹配的自定义供应商配置,请检查模型名称'\nexit 0\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake agent: %v", err)
}
f := &execForwarder{name: "claudecode", argv: []string{bin}, timeout: 10_000_000_000}
got, err := f.forward(context.Background(), "conv-1", "hi")
if err != nil {
t.Fatalf("forward returned err: %v", err)
}
if strings.HasPrefix(strings.TrimSpace(got), "API Error:") {
t.Fatalf("raw backend error was forwarded as the answer: %q", got)
}
if !strings.Contains(got, "AI 后端调用失败") || !strings.Contains(got, "--agent-model") {
t.Fatalf("expected actionable hint, got: %q", got)
}
// A normal reply is untouched.
okBin := filepath.Join(stub, "okagent")
if err := os.WriteFile(okBin, []byte("#!/bin/sh\necho '你好,这是正常回复'\n"), 0o755); err != nil {
t.Fatalf("write ok agent: %v", err)
}
f2 := &execForwarder{name: "claudecode", argv: []string{okBin}, timeout: 10_000_000_000}
got2, err := f2.forward(context.Background(), "conv-2", "hi")
if err != nil {
t.Fatalf("forward (normal) err: %v", err)
}
if strings.TrimSpace(got2) != "你好,这是正常回复" {
t.Fatalf("normal reply altered: %q", got2)
}
}
// TestAgentBackendErrorHelpers locks the heuristic and message shape.
func TestAgentBackendErrorHelpers(t *testing.T) {
if !agentReplyIsError("API Error: 422 boom") {
t.Fatal("API Error prefix should be detected")
}
if agentReplyIsError("这是正常回复,里面提到 API Error 不算") {
t.Fatal("mid-text mention must not be flagged")
}
msg := agentBackendErrorReply("API Error: 422 line one\nline two ignored")
if strings.Contains(msg, "line two") {
t.Fatalf("only first line should be kept: %q", msg)
}
if !strings.Contains(msg, "--agent-model") {
t.Fatalf("hint must mention --agent-model: %q", msg)
}
}
// TestStripModelArg covers removing a flag+value and the absent-flag no-op.
func TestStripModelArg(t *testing.T) {
got := stripModelArg([]string{"claude", "-p", "--model", "haiku", "--strict-mcp-config"}, "--model")
want := "claude -p --strict-mcp-config"
if strings.Join(got, " ") != want {
t.Fatalf("strip: got %v, want %s", got, want)
}
noop := stripModelArg([]string{"claude", "-p"}, "--model")
if strings.Join(noop, " ") != "claude -p" {
t.Fatalf("no-op: got %v", noop)
}
}