mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
feat: provider switch fixes, agent type switching, integration tests & i18n
- Fix provider switch not taking effect: clear agent session ID on switch to force fresh session instead of resuming with stale config - Fix ANTHROPIC_MODEL env leak from cc-switch: explicitly set model env when provider has custom BaseURL - Add "clear provider" option in Feishu card for unsetting active provider - Add debug logging for provider env vars and session startup commands (with secret redaction via RedactEnv/RedactArgs) - Web UI: add agent type switcher in project settings with provider compatibility filtering and restart-required flow - Web UI: filter global providers by agent_types when linking to project - Web UI: fix heartbeat tab showing "running" for all projects (now shows "not configured" when heartbeat is not enabled) - Web UI: fix page not refreshing after agent type change + restart - Add comprehensive integration tests for both claudecode and codex provider switching (env vars, session args, config writing, send/receive) - Complete i18n for all 5 languages: replyFooter, injectSender, thinkingMessages, toolMessages and their hints Made-with: Cursor
This commit is contained in:
1 parent
c4279d142e
commit
d02a5f1370
19 files changed
+1049
-18
No files matched your search
@@ -390,11 +390,20 @@ func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentS
|
||||
effort := a.reasoningEffort
|
||||
extraEnv := a.runtimeEnvLocked()
|
||||
|
||||
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
||||
if m := a.providers[a.activeIdx].Model; m != "" {
|
||||
activeIdx := a.activeIdx
|
||||
var activeProviderName string
|
||||
if activeIdx >= 0 && activeIdx < len(a.providers) {
|
||||
activeProviderName = a.providers[activeIdx].Name
|
||||
if m := a.providers[activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
slog.Debug("claudecode: StartSession provider state",
|
||||
"activeIdx", activeIdx,
|
||||
"activeProvider", activeProviderName,
|
||||
"model", model,
|
||||
"sessionID", sessionID,
|
||||
"providerCount", len(a.providers))
|
||||
platformPrompt := a.platformPrompt
|
||||
// When router_url is set, --verbose conflicts with --output-format stream-json
|
||||
// (verbose emits non-JSON text to stdout that corrupts the JSON stream).
|
||||
@@ -919,6 +928,9 @@ func (a *Agent) providerEnvLocked() []string {
|
||||
env = append(env, "ANTHROPIC_AUTH_TOKEN="+p.APIKey)
|
||||
env = append(env, "ANTHROPIC_API_KEY=")
|
||||
}
|
||||
if p.Model != "" {
|
||||
env = append(env, "ANTHROPIC_MODEL="+p.Model)
|
||||
}
|
||||
} else {
|
||||
a.stopProviderProxyLocked()
|
||||
if p.APIKey != "" {
|
||||
@@ -929,6 +941,10 @@ func (a *Agent) providerEnvLocked() []string {
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
slog.Debug("claudecode: providerEnv",
|
||||
"provider", p.Name,
|
||||
"model", p.Model,
|
||||
"env", core.RedactEnv(env))
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,114 @@ func TestAgentUsageProbeEnv_AddsHostManagedFlagForRouterOverrides(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderEnv_SetsAnthropicModel(t *testing.T) {
|
||||
a := &Agent{
|
||||
providers: []core.ProviderConfig{
|
||||
{
|
||||
Name: "provider-a",
|
||||
BaseURL: "https://a.example.com/v1",
|
||||
APIKey: "key-a",
|
||||
Model: "model-a",
|
||||
},
|
||||
{
|
||||
Name: "provider-b",
|
||||
BaseURL: "https://b.example.com/v1",
|
||||
APIKey: "key-b",
|
||||
Model: "model-b",
|
||||
},
|
||||
},
|
||||
activeIdx: 0,
|
||||
}
|
||||
|
||||
env := envSliceToMap(a.providerEnvLocked())
|
||||
if got := env["ANTHROPIC_MODEL"]; got != "model-a" {
|
||||
t.Fatalf("ANTHROPIC_MODEL = %q, want %q", got, "model-a")
|
||||
}
|
||||
if got := env["ANTHROPIC_BASE_URL"]; got != "https://a.example.com/v1" {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL = %q, want provider-a URL", got)
|
||||
}
|
||||
|
||||
a.SetActiveProvider("provider-b")
|
||||
env = envSliceToMap(a.providerEnvLocked())
|
||||
if got := env["ANTHROPIC_MODEL"]; got != "model-b" {
|
||||
t.Fatalf("after switch: ANTHROPIC_MODEL = %q, want %q", got, "model-b")
|
||||
}
|
||||
if got := env["ANTHROPIC_BASE_URL"]; got != "https://b.example.com/v1" {
|
||||
t.Fatalf("after switch: ANTHROPIC_BASE_URL = %q, want provider-b URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderEnv_NoModelWhenEmpty(t *testing.T) {
|
||||
a := &Agent{
|
||||
providers: []core.ProviderConfig{
|
||||
{
|
||||
Name: "no-model",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIKey: "key",
|
||||
},
|
||||
},
|
||||
activeIdx: 0,
|
||||
}
|
||||
env := envSliceToMap(a.providerEnvLocked())
|
||||
if _, ok := env["ANTHROPIC_MODEL"]; ok {
|
||||
t.Fatalf("ANTHROPIC_MODEL should not be set when provider has no model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderEnv_ClearReturnsNil(t *testing.T) {
|
||||
a := &Agent{
|
||||
providers: []core.ProviderConfig{
|
||||
{Name: "p", BaseURL: "https://x.com", APIKey: "k", Model: "m"},
|
||||
},
|
||||
activeIdx: 0,
|
||||
}
|
||||
a.SetActiveProvider("")
|
||||
env := a.providerEnvLocked()
|
||||
if env != nil {
|
||||
t.Fatalf("expected nil env after clearing provider, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartSession_UsesActiveProviderModel(t *testing.T) {
|
||||
a := &Agent{
|
||||
model: "default-model",
|
||||
providers: []core.ProviderConfig{
|
||||
{Name: "p1", Model: "provider-model-1"},
|
||||
{Name: "p2", Model: "provider-model-2"},
|
||||
},
|
||||
activeIdx: 0,
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
activeIdx := a.activeIdx
|
||||
model := a.model
|
||||
if activeIdx >= 0 && activeIdx < len(a.providers) {
|
||||
if m := a.providers[activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if model != "provider-model-1" {
|
||||
t.Fatalf("model = %q, want %q", model, "provider-model-1")
|
||||
}
|
||||
|
||||
a.SetActiveProvider("p2")
|
||||
a.mu.Lock()
|
||||
activeIdx = a.activeIdx
|
||||
model = a.model
|
||||
if activeIdx >= 0 && activeIdx < len(a.providers) {
|
||||
if m := a.providers[activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if model != "provider-model-2" {
|
||||
t.Fatalf("after switch: model = %q, want %q", model, "provider-model-2")
|
||||
}
|
||||
}
|
||||
|
||||
func envSliceToMap(env []string) map[string]string {
|
||||
out := make(map[string]string, len(env))
|
||||
for _, entry := range env {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// These integration tests use real provider credentials from ~/.cc-connect/config.toml.
|
||||
// They verify that provider switching correctly sets up env vars and that agent
|
||||
// sessions can be started with the right configuration.
|
||||
//
|
||||
// Skip in CI: set CC_SKIP_INTEGRATION=1 or simply don't have the config file.
|
||||
|
||||
func skipIfNoConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
if os.Getenv("CC_SKIP_INTEGRATION") == "1" {
|
||||
t.Skip("CC_SKIP_INTEGRATION=1")
|
||||
}
|
||||
cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml")
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
t.Skipf("config not found at %s", cfgPath)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
cfg.ResolveProviderRefs()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configToCoreProv(p config.ProviderConfig) core.ProviderConfig {
|
||||
cp := core.ProviderConfig{
|
||||
Name: p.Name,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Model: p.Model,
|
||||
Env: p.Env,
|
||||
}
|
||||
if p.Thinking != "" {
|
||||
cp.Thinking = p.Thinking
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
cp.Models = append(cp.Models, core.ModelOption{Name: m.Model})
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func findProjectProviders(cfg *config.Config, agentType string) (projName string, providers []core.ProviderConfig, workDir string) {
|
||||
for i := range cfg.Projects {
|
||||
proj := &cfg.Projects[i]
|
||||
if proj.Agent.Type != agentType || len(proj.Agent.Providers) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, p := range proj.Agent.Providers {
|
||||
providers = append(providers, configToCoreProv(p))
|
||||
}
|
||||
projName = proj.Name
|
||||
if wd, ok := proj.Agent.Options["work_dir"].(string); ok {
|
||||
workDir = wd
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestIntegration_ProviderSwitch_EnvVars(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, _ := findProjectProviders(cfg, "claudecode")
|
||||
if name == "" {
|
||||
t.Skip("no claudecode project with providers found")
|
||||
}
|
||||
if len(providers) < 2 {
|
||||
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
providers: providers,
|
||||
activeIdx: -1,
|
||||
}
|
||||
|
||||
p0, p1 := providers[0], providers[1]
|
||||
t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL)
|
||||
t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL)
|
||||
|
||||
a.SetActiveProvider(p0.Name)
|
||||
env0 := envSliceToMap(a.providerEnvLocked())
|
||||
|
||||
if p0.BaseURL != "" {
|
||||
if got := env0["ANTHROPIC_BASE_URL"]; got != p0.BaseURL {
|
||||
t.Errorf("provider[0] ANTHROPIC_BASE_URL = %q, want %q", got, p0.BaseURL)
|
||||
}
|
||||
}
|
||||
if p0.Model != "" {
|
||||
if got := env0["ANTHROPIC_MODEL"]; got != p0.Model {
|
||||
t.Errorf("provider[0] ANTHROPIC_MODEL = %q, want %q", got, p0.Model)
|
||||
}
|
||||
}
|
||||
|
||||
a.SetActiveProvider(p1.Name)
|
||||
env1 := envSliceToMap(a.providerEnvLocked())
|
||||
|
||||
if p1.BaseURL != "" {
|
||||
if got := env1["ANTHROPIC_BASE_URL"]; got != p1.BaseURL {
|
||||
t.Errorf("provider[1] ANTHROPIC_BASE_URL = %q, want %q", got, p1.BaseURL)
|
||||
}
|
||||
}
|
||||
if p1.Model != "" {
|
||||
if got := env1["ANTHROPIC_MODEL"]; got != p1.Model {
|
||||
t.Errorf("provider[1] ANTHROPIC_MODEL = %q, want %q", got, p1.Model)
|
||||
}
|
||||
}
|
||||
|
||||
if p0.BaseURL != p1.BaseURL {
|
||||
if env0["ANTHROPIC_BASE_URL"] == env1["ANTHROPIC_BASE_URL"] {
|
||||
t.Error("providers have different base_urls but env didn't change after switch")
|
||||
}
|
||||
}
|
||||
if p0.Model != p1.Model && p0.Model != "" && p1.Model != "" {
|
||||
if env0["ANTHROPIC_MODEL"] == env1["ANTHROPIC_MODEL"] {
|
||||
t.Error("providers have different models but ANTHROPIC_MODEL didn't change after switch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ProviderSwitch_SessionStartModel(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, workDir := findProjectProviders(cfg, "claudecode")
|
||||
if name == "" {
|
||||
t.Skip("no claudecode project with providers found")
|
||||
}
|
||||
if len(providers) < 2 {
|
||||
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
|
||||
}
|
||||
if workDir == "" {
|
||||
workDir = "/tmp"
|
||||
}
|
||||
|
||||
cliBin, err := exec.LookPath("claude")
|
||||
if err != nil {
|
||||
t.Skipf("claude CLI not found: %v", err)
|
||||
}
|
||||
|
||||
for _, prov := range providers[:2] {
|
||||
t.Run(prov.Name, func(t *testing.T) {
|
||||
a := &Agent{
|
||||
model: "default-model",
|
||||
providers: providers,
|
||||
activeIdx: -1,
|
||||
workDir: workDir,
|
||||
cliBin: cliBin,
|
||||
}
|
||||
a.SetActiveProvider(prov.Name)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sess, err := a.StartSession(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession failed: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
cs := sess.(*claudeSession)
|
||||
envMap := envSliceToMap(cs.cmd.Env)
|
||||
|
||||
if prov.Model != "" {
|
||||
found := false
|
||||
for _, arg := range cs.cmd.Args {
|
||||
if arg == prov.Model {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("--model %q not found in args: %v", prov.Model, cs.cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
if prov.BaseURL != "" {
|
||||
gotURL := envMap["ANTHROPIC_BASE_URL"]
|
||||
if gotURL == "" {
|
||||
t.Error("ANTHROPIC_BASE_URL not set in session env")
|
||||
} else if !strings.Contains(gotURL, "127.0.0.1") && gotURL != prov.BaseURL {
|
||||
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q (or proxy URL)", gotURL, prov.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
if prov.Model != "" {
|
||||
if got := envMap["ANTHROPIC_MODEL"]; got != prov.Model {
|
||||
t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, prov.Model)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("session started OK: pid=%d, model=%s, env_model=%s, env_base_url=%s",
|
||||
cs.cmd.Process.Pid,
|
||||
prov.Model,
|
||||
envMap["ANTHROPIC_MODEL"],
|
||||
envMap["ANTHROPIC_BASE_URL"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_CodexProvider_EnvVars(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, _ := findProjectProviders(cfg, "codex")
|
||||
if name == "" {
|
||||
t.Skip("no codex project with providers found")
|
||||
}
|
||||
|
||||
for _, prov := range providers {
|
||||
t.Run(prov.Name, func(t *testing.T) {
|
||||
t.Logf("codex provider: name=%s base_url=%s model=%s", prov.Name, prov.BaseURL, prov.Model)
|
||||
if prov.BaseURL == "" {
|
||||
t.Error("codex provider should have a base_url")
|
||||
}
|
||||
if prov.Model == "" {
|
||||
t.Error("codex provider should have a model")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_AgentTypeChange_FiltersProviders(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
if len(cfg.Providers) == 0 {
|
||||
t.Skip("no global providers in config")
|
||||
}
|
||||
|
||||
globalByName := make(map[string]config.ProviderConfig, len(cfg.Providers))
|
||||
for _, p := range cfg.Providers {
|
||||
globalByName[p.Name] = p
|
||||
}
|
||||
|
||||
for i := range cfg.Projects {
|
||||
proj := &cfg.Projects[i]
|
||||
if proj.Agent.Type != "claudecode" || len(proj.Agent.ProviderRefs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var compatible, incompatible []string
|
||||
for _, ref := range proj.Agent.ProviderRefs {
|
||||
gp, ok := globalByName[ref]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(gp.AgentTypes) > 0 {
|
||||
hasCodex := false
|
||||
for _, at := range gp.AgentTypes {
|
||||
if at == "codex" {
|
||||
hasCodex = true
|
||||
}
|
||||
}
|
||||
if hasCodex {
|
||||
compatible = append(compatible, ref)
|
||||
} else {
|
||||
incompatible = append(incompatible, ref)
|
||||
}
|
||||
} else {
|
||||
compatible = append(compatible, ref)
|
||||
}
|
||||
}
|
||||
|
||||
if len(incompatible) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Logf("project %q: switching claudecode→codex: keep=%v remove=%v",
|
||||
proj.Name, compatible, incompatible)
|
||||
t.Logf("provider filtering validated: %d provider(s) would be removed on agent type change", len(incompatible))
|
||||
return
|
||||
}
|
||||
t.Skip("no project with incompatible providers found for agent type change test")
|
||||
}
|
||||
@@ -152,6 +152,21 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
|
||||
env = core.FilterEnvForSpawn(env, spawnOpts)
|
||||
cmd.Env = env
|
||||
|
||||
var providerEnvSnapshot []string
|
||||
for _, e := range env {
|
||||
for _, prefix := range []string{"ANTHROPIC_", "CLAUDE_", "AWS_", "NO_PROXY", "DISABLE_"} {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
providerEnvSnapshot = append(providerEnvSnapshot, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("claudeSession: spawn details",
|
||||
"bin", cliBin,
|
||||
"allArgs", core.RedactArgs(allArgs),
|
||||
"model", model,
|
||||
"providerEnv", core.RedactEnv(providerEnvSnapshot))
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func skipIfNoConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
if os.Getenv("CC_SKIP_INTEGRATION") == "1" {
|
||||
t.Skip("CC_SKIP_INTEGRATION=1")
|
||||
}
|
||||
cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml")
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
t.Skipf("config not found at %s", cfgPath)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
cfg.ResolveProviderRefs()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configToCoreProv(p config.ProviderConfig) core.ProviderConfig {
|
||||
cp := core.ProviderConfig{
|
||||
Name: p.Name,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Model: p.Model,
|
||||
Env: p.Env,
|
||||
}
|
||||
if p.Codex != nil {
|
||||
cp.CodexWireAPI = p.Codex.WireAPI
|
||||
cp.CodexHTTPHeaders = p.Codex.HTTPHeaders
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
cp.Models = append(cp.Models, core.ModelOption{Name: m.Model})
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func envSliceToMap(env []string) map[string]string {
|
||||
out := make(map[string]string, len(env))
|
||||
for _, entry := range env {
|
||||
key, value, ok := strings.Cut(entry, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findCodexProject(cfg *config.Config) (name string, providers []core.ProviderConfig, workDir, codexHome string) {
|
||||
for i := range cfg.Projects {
|
||||
proj := &cfg.Projects[i]
|
||||
if proj.Agent.Type != "codex" || len(proj.Agent.Providers) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, p := range proj.Agent.Providers {
|
||||
providers = append(providers, configToCoreProv(p))
|
||||
}
|
||||
name = proj.Name
|
||||
if wd, ok := proj.Agent.Options["work_dir"].(string); ok {
|
||||
workDir = wd
|
||||
}
|
||||
if ch, ok := proj.Agent.Options["codex_home"].(string); ok {
|
||||
codexHome = ch
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestIntegration_Codex_ProviderSwitch_EnvVars(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, _, _ := findCodexProject(cfg)
|
||||
if name == "" {
|
||||
t.Skip("no codex project with providers found")
|
||||
}
|
||||
if len(providers) < 2 {
|
||||
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
providers: providers,
|
||||
activeIdx: -1,
|
||||
}
|
||||
|
||||
p0, p1 := providers[0], providers[1]
|
||||
t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL)
|
||||
t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL)
|
||||
|
||||
a.SetActiveProvider(p0.Name)
|
||||
a.mu.RLock()
|
||||
env0 := envSliceToMap(a.providerEnvLocked())
|
||||
a.mu.RUnlock()
|
||||
|
||||
if p0.BaseURL != "" {
|
||||
if got := env0["OPENAI_BASE_URL"]; got != p0.BaseURL {
|
||||
t.Errorf("provider[0] OPENAI_BASE_URL = %q, want %q", got, p0.BaseURL)
|
||||
}
|
||||
}
|
||||
if p0.APIKey != "" {
|
||||
if got := env0["OPENAI_API_KEY"]; got == "" {
|
||||
t.Error("provider[0] OPENAI_API_KEY not set")
|
||||
}
|
||||
}
|
||||
|
||||
a.SetActiveProvider(p1.Name)
|
||||
a.mu.RLock()
|
||||
env1 := envSliceToMap(a.providerEnvLocked())
|
||||
a.mu.RUnlock()
|
||||
|
||||
if p1.BaseURL != "" {
|
||||
if got := env1["OPENAI_BASE_URL"]; got != p1.BaseURL {
|
||||
t.Errorf("provider[1] OPENAI_BASE_URL = %q, want %q", got, p1.BaseURL)
|
||||
}
|
||||
}
|
||||
if p1.APIKey != "" {
|
||||
if got := env1["OPENAI_API_KEY"]; got == "" {
|
||||
t.Error("provider[1] OPENAI_API_KEY not set")
|
||||
}
|
||||
}
|
||||
|
||||
if p0.BaseURL != p1.BaseURL {
|
||||
if env0["OPENAI_BASE_URL"] == env1["OPENAI_BASE_URL"] {
|
||||
t.Error("providers have different base_urls but OPENAI_BASE_URL didn't change after switch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Codex_ProviderSwitch_SessionArgs(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, workDir, codexHome := findCodexProject(cfg)
|
||||
if name == "" {
|
||||
t.Skip("no codex project with providers found")
|
||||
}
|
||||
if workDir == "" {
|
||||
workDir = t.TempDir()
|
||||
}
|
||||
|
||||
for _, prov := range providers {
|
||||
t.Run(prov.Name, func(t *testing.T) {
|
||||
a := &Agent{
|
||||
model: "default-model",
|
||||
providers: providers,
|
||||
activeIdx: -1,
|
||||
workDir: workDir,
|
||||
codexHome: codexHome,
|
||||
mode: "suggest",
|
||||
backend: "exec",
|
||||
}
|
||||
a.SetActiveProvider(prov.Name)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sess, err := a.StartSession(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession failed: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
cs := sess.(*codexSession)
|
||||
|
||||
if prov.Model != "" && cs.model != prov.Model {
|
||||
t.Errorf("session model = %q, want %q", cs.model, prov.Model)
|
||||
}
|
||||
if prov.BaseURL != "" && cs.baseURL != prov.BaseURL {
|
||||
t.Errorf("session baseURL = %q, want %q", cs.baseURL, prov.BaseURL)
|
||||
}
|
||||
|
||||
envMap := envSliceToMap(cs.extraEnv)
|
||||
if prov.APIKey != "" {
|
||||
if got := envMap["OPENAI_API_KEY"]; got == "" {
|
||||
t.Error("OPENAI_API_KEY not set in session extraEnv")
|
||||
}
|
||||
}
|
||||
if prov.BaseURL != "" {
|
||||
if got := envMap["OPENAI_BASE_URL"]; got != prov.BaseURL {
|
||||
t.Errorf("OPENAI_BASE_URL = %q, want %q", got, prov.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("session OK: model=%s baseURL=%s modelProvider=%s",
|
||||
cs.model, cs.baseURL, cs.modelProvider)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Codex_ProviderConfig_WrittenCorrectly(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
|
||||
name, providers, _, _ := findCodexProject(cfg)
|
||||
if name == "" {
|
||||
t.Skip("no codex project with providers found")
|
||||
}
|
||||
|
||||
for _, prov := range providers {
|
||||
t.Run(prov.Name, func(t *testing.T) {
|
||||
if prov.BaseURL == "" || prov.APIKey == "" {
|
||||
t.Skip("provider has no base_url or api_key")
|
||||
}
|
||||
|
||||
home := filepath.Join(t.TempDir(), ".codex")
|
||||
|
||||
err := ensureCodexProviderConfig(home, prov.Name, prov.BaseURL, prov.CodexWireAPI, prov.CodexHTTPHeaders)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureCodexProviderConfig failed: %v", err)
|
||||
}
|
||||
|
||||
cfgData, err := os.ReadFile(filepath.Join(home, "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read config.toml: %v", err)
|
||||
}
|
||||
cfgContent := string(cfgData)
|
||||
|
||||
if !strings.Contains(cfgContent, `[model_providers.`+prov.Name+`]`) {
|
||||
t.Errorf("config.toml missing provider section for %s", prov.Name)
|
||||
}
|
||||
if !strings.Contains(cfgContent, prov.BaseURL) {
|
||||
t.Errorf("config.toml missing base_url %s", prov.BaseURL)
|
||||
}
|
||||
|
||||
err = ensureCodexAuth(home, prov.APIKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureCodexAuth failed: %v", err)
|
||||
}
|
||||
|
||||
authData, err := os.ReadFile(filepath.Join(home, "auth.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read auth.json: %v", err)
|
||||
}
|
||||
var authMap map[string]any
|
||||
if err := json.Unmarshal(authData, &authMap); err != nil {
|
||||
t.Fatalf("parse auth.json: %v", err)
|
||||
}
|
||||
if authMap["OPENAI_API_KEY"] != prov.APIKey {
|
||||
t.Error("OPENAI_API_KEY not written correctly to auth.json")
|
||||
}
|
||||
|
||||
t.Logf("provider config written OK: name=%s wireAPI=%s", prov.Name, prov.CodexWireAPI)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Codex_ProviderSwitch_SendMessage(t *testing.T) {
|
||||
cfg := skipIfNoConfig(t)
|
||||
if _, err := exec.LookPath("codex"); err != nil {
|
||||
t.Skip("codex CLI not in PATH")
|
||||
}
|
||||
|
||||
name, providers, workDir, codexHome := findCodexProject(cfg)
|
||||
if name == "" {
|
||||
t.Skip("no codex project with providers found")
|
||||
}
|
||||
if codexHome == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
codexHome = filepath.Join(home, ".codex")
|
||||
}
|
||||
|
||||
if workDir == "" {
|
||||
workDir = t.TempDir()
|
||||
gitInit := exec.Command("git", "init", "-q")
|
||||
gitInit.Dir = workDir
|
||||
if err := gitInit.Run(); err != nil {
|
||||
t.Fatalf("git init: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prov := range providers {
|
||||
t.Run(prov.Name, func(t *testing.T) {
|
||||
if prov.APIKey == "" || prov.BaseURL == "" {
|
||||
t.Skip("provider missing api_key or base_url")
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
model: "default-model",
|
||||
providers: providers,
|
||||
activeIdx: -1,
|
||||
workDir: workDir,
|
||||
codexHome: codexHome,
|
||||
mode: "full-auto",
|
||||
backend: "exec",
|
||||
}
|
||||
a.SetActiveProvider(prov.Name)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sess, err := a.StartSession(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession failed: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
err = sess.Send("reply with exactly 'codex-provider-ok' and nothing else", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Send failed: %v", err)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(25 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
var allText strings.Builder
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-sess.Events():
|
||||
if !ok {
|
||||
if allText.Len() > 0 && strings.Contains(allText.String(), "codex-provider-ok") {
|
||||
t.Logf("provider %s: send+receive OK (from text events)", prov.Name)
|
||||
return
|
||||
}
|
||||
t.Fatalf("event channel closed, collected text: %s", allText.String())
|
||||
return
|
||||
}
|
||||
t.Logf("event: type=%s content_len=%d", ev.Type, len(ev.Content))
|
||||
if ev.Type == core.EventText || ev.Type == core.EventResult {
|
||||
allText.WriteString(ev.Content)
|
||||
}
|
||||
if ev.Type == core.EventResult {
|
||||
if strings.Contains(allText.String(), "codex-provider-ok") {
|
||||
t.Logf("provider %s: send+receive OK", prov.Name)
|
||||
} else {
|
||||
t.Errorf("expected 'codex-provider-ok' in output, got: %s", allText.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if ev.Type == core.EventError {
|
||||
t.Fatalf("agent error: %s", ev.Content)
|
||||
}
|
||||
case <-timer.C:
|
||||
if strings.Contains(allText.String(), "codex-provider-ok") {
|
||||
t.Logf("provider %s: send+receive OK (timeout but got result)", prov.Name)
|
||||
return
|
||||
}
|
||||
t.Fatalf("timeout waiting for codex response, collected: %s", allText.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -847,6 +847,7 @@ func main() {
|
||||
DisabledCommands: u.DisabledCommands,
|
||||
WorkDir: u.WorkDir,
|
||||
Mode: u.Mode,
|
||||
AgentType: u.AgentType,
|
||||
ShowContextIndicator: u.ShowContextIndicator,
|
||||
ReplyFooter: u.ReplyFooter,
|
||||
InjectSender: u.InjectSender,
|
||||
|
||||
@@ -2403,6 +2403,7 @@ type ProjectSettingsUpdate struct {
|
||||
DisabledCommands []string
|
||||
WorkDir *string
|
||||
Mode *string
|
||||
AgentType *string
|
||||
ShowContextIndicator *bool
|
||||
ReplyFooter *bool
|
||||
InjectSender *bool
|
||||
@@ -2434,6 +2435,45 @@ func SaveProjectSettings(projectName string, update ProjectSettingsUpdate) error
|
||||
continue
|
||||
}
|
||||
proj := &cfg.Projects[i]
|
||||
if update.AgentType != nil && *update.AgentType != proj.Agent.Type {
|
||||
newType := *update.AgentType
|
||||
proj.Agent.Type = newType
|
||||
// Filter out provider_refs incompatible with the new agent type.
|
||||
globalByName := make(map[string]ProviderConfig, len(cfg.Providers))
|
||||
for _, p := range cfg.Providers {
|
||||
globalByName[p.Name] = p
|
||||
}
|
||||
var compatible []string
|
||||
for _, ref := range proj.Agent.ProviderRefs {
|
||||
gp, ok := globalByName[ref]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(gp.AgentTypes) > 0 && !containsString(gp.AgentTypes, newType) {
|
||||
slog.Info("removing incompatible provider ref on agent type change",
|
||||
"project", projectName, "provider", ref,
|
||||
"provider_agents", gp.AgentTypes, "new_agent", newType)
|
||||
continue
|
||||
}
|
||||
compatible = append(compatible, ref)
|
||||
}
|
||||
proj.Agent.ProviderRefs = compatible
|
||||
// Clear active provider if it was removed.
|
||||
if opts := proj.Agent.Options; opts != nil {
|
||||
if prov, ok := opts["provider"].(string); ok && prov != "" {
|
||||
found := false
|
||||
for _, ref := range compatible {
|
||||
if ref == prov {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
delete(opts, "provider")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if update.AdminFrom != nil {
|
||||
proj.AdminFrom = *update.AdminFrom
|
||||
}
|
||||
|
||||
+27
-2
@@ -6598,6 +6598,12 @@ func (e *Engine) cmdProvider(p Platform, msg *Message, args []string) {
|
||||
case "clear", "reset", "none":
|
||||
switcher.SetActiveProvider("")
|
||||
e.cleanupInteractiveState(e.interactiveKeyForSessionKey(msg.SessionKey))
|
||||
{
|
||||
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
||||
s.SetAgentSessionID("", "")
|
||||
s.ClearHistory()
|
||||
e.sessions.Save()
|
||||
}
|
||||
if e.providerSaveFunc != nil {
|
||||
if err := e.providerSaveFunc(""); err != nil {
|
||||
slog.Error("failed to save provider", "error", err)
|
||||
@@ -6744,6 +6750,11 @@ func (e *Engine) switchProvider(p Platform, msg *Message, switcher ProviderSwitc
|
||||
}
|
||||
e.cleanupInteractiveState(e.interactiveKeyForSessionKey(msg.SessionKey))
|
||||
|
||||
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
||||
s.SetAgentSessionID("", "")
|
||||
s.ClearHistory()
|
||||
e.sessions.Save()
|
||||
|
||||
if e.providerSaveFunc != nil {
|
||||
if err := e.providerSaveFunc(name); err != nil {
|
||||
slog.Error("failed to save provider", "error", err)
|
||||
@@ -7716,11 +7727,19 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if switcher.SetActiveProvider(args) {
|
||||
provName := args
|
||||
if provName == "clear" {
|
||||
provName = ""
|
||||
}
|
||||
if switcher.SetActiveProvider(provName) {
|
||||
interactiveKey := e.interactiveKeyForSessionKey(sessionKey)
|
||||
e.cleanupInteractiveState(interactiveKey)
|
||||
s := e.sessions.GetOrCreateActive(sessionKey)
|
||||
s.SetAgentSessionID("", "")
|
||||
s.ClearHistory()
|
||||
e.sessions.Save()
|
||||
if e.providerSaveFunc != nil {
|
||||
_ = e.providerSaveFunc(args)
|
||||
_ = e.providerSaveFunc(provName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8767,6 +8786,12 @@ func (e *Engine) renderProviderCard() *Card {
|
||||
if len(providers) > 0 {
|
||||
var opts []CardSelectOption
|
||||
initVal := ""
|
||||
if current != nil {
|
||||
opts = append(opts, CardSelectOption{
|
||||
Text: "🚫 " + e.i18n.T(MsgProviderClearOption),
|
||||
Value: "act:/provider clear",
|
||||
})
|
||||
}
|
||||
for _, prov := range providers {
|
||||
label := prov.Name
|
||||
if prov.BaseURL != "" {
|
||||
|
||||
@@ -299,6 +299,7 @@ const (
|
||||
MsgModelSelectPlaceholder MsgKey = "model_select_placeholder"
|
||||
MsgModeSelectPlaceholder MsgKey = "mode_select_placeholder"
|
||||
MsgProviderSelectPlaceholder MsgKey = "provider_select_placeholder"
|
||||
MsgProviderClearOption MsgKey = "provider_clear_option"
|
||||
MsgCardBack MsgKey = "card_back"
|
||||
MsgCardPrev MsgKey = "card_prev"
|
||||
MsgCardNext MsgKey = "card_next"
|
||||
@@ -2193,6 +2194,10 @@ var messages = map[MsgKey]map[Language]string{
|
||||
LangEnglish: "Select provider", LangChinese: "选择 Provider", LangTraditionalChinese: "選擇 Provider",
|
||||
LangJapanese: "プロバイダーを選択", LangSpanish: "Seleccionar proveedor",
|
||||
},
|
||||
MsgProviderClearOption: {
|
||||
LangEnglish: "Do not use provider", LangChinese: "不使用服务商", LangTraditionalChinese: "不使用服務商",
|
||||
LangJapanese: "プロバイダーを使用しない", LangSpanish: "No usar proveedor",
|
||||
},
|
||||
MsgCardBack: {
|
||||
LangEnglish: "← Back", LangChinese: "← 返回", LangTraditionalChinese: "← 返回",
|
||||
LangJapanese: "← 戻る", LangSpanish: "← Volver",
|
||||
|
||||
+39
-1
@@ -23,6 +23,7 @@ type ProjectSettingsUpdate struct {
|
||||
DisabledCommands []string
|
||||
WorkDir *string
|
||||
Mode *string
|
||||
AgentType *string
|
||||
ShowContextIndicator *bool
|
||||
ReplyFooter *bool
|
||||
InjectSender *bool
|
||||
@@ -212,6 +213,9 @@ func (m *ManagementServer) buildHandler(mux *http.ServeMux) http.Handler {
|
||||
mux.HandleFunc(prefix+"/config", m.wrap(m.handleConfig))
|
||||
mux.HandleFunc(prefix+"/settings", m.wrap(m.handleGlobalSettings))
|
||||
|
||||
// Agents & Platforms (registry)
|
||||
mux.HandleFunc(prefix+"/agents", m.wrap(m.handleAgents))
|
||||
|
||||
// Projects
|
||||
mux.HandleFunc(prefix+"/projects", m.wrap(m.handleProjects))
|
||||
mux.HandleFunc(prefix+"/projects/", m.wrap(m.handleProjectRoutes))
|
||||
@@ -365,6 +369,17 @@ func mgmtOK(w http.ResponseWriter, msg string) {
|
||||
|
||||
// ── System endpoints ──────────────────────────────────────────
|
||||
|
||||
func (m *ManagementServer) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET only")
|
||||
return
|
||||
}
|
||||
mgmtJSON(w, http.StatusOK, map[string]any{
|
||||
"agents": ListRegisteredAgents(),
|
||||
"platforms": ListRegisteredPlatforms(),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ManagementServer) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET only")
|
||||
@@ -682,6 +697,7 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
DisabledCommands []string `json:"disabled_commands"`
|
||||
WorkDir *string `json:"work_dir"`
|
||||
Mode *string `json:"mode"`
|
||||
AgentType *string `json:"agent_type"`
|
||||
ShowContextIndicator *bool `json:"show_context_indicator"`
|
||||
ReplyFooter *bool `json:"reply_footer"`
|
||||
InjectSender *bool `json:"inject_sender"`
|
||||
@@ -732,6 +748,23 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
e.SetInjectSender(*body.InjectSender)
|
||||
}
|
||||
|
||||
restartRequired := false
|
||||
if body.AgentType != nil && *body.AgentType != e.agent.Name() {
|
||||
registered := ListRegisteredAgents()
|
||||
found := false
|
||||
for _, a := range registered {
|
||||
if a == *body.AgentType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
mgmtError(w, http.StatusBadRequest, fmt.Sprintf("unknown agent type %q", *body.AgentType))
|
||||
return
|
||||
}
|
||||
restartRequired = true
|
||||
}
|
||||
|
||||
if m.saveProjectSettings != nil {
|
||||
patch := ProjectSettingsUpdate{
|
||||
Language: body.Language,
|
||||
@@ -739,6 +772,7 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
DisabledCommands: body.DisabledCommands,
|
||||
WorkDir: body.WorkDir,
|
||||
Mode: body.Mode,
|
||||
AgentType: body.AgentType,
|
||||
ShowContextIndicator: body.ShowContextIndicator,
|
||||
ReplyFooter: body.ReplyFooter,
|
||||
InjectSender: body.InjectSender,
|
||||
@@ -749,7 +783,11 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
mgmtOK(w, "settings updated")
|
||||
resp := map[string]any{"message": "settings updated"}
|
||||
if restartRequired {
|
||||
resp["restart_required"] = true
|
||||
}
|
||||
mgmtJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,36 @@ package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// RedactEnv returns a copy of env with values of sensitive keys masked.
|
||||
// Only env vars whose key contains a sensitive substring are redacted.
|
||||
func RedactEnv(env []string) []string {
|
||||
sensitiveKeys := []string{
|
||||
"KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
||||
}
|
||||
out := make([]string, len(env))
|
||||
for i, e := range env {
|
||||
idx := strings.IndexByte(e, '=')
|
||||
if idx < 0 {
|
||||
out[i] = e
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(e[:idx])
|
||||
redact := false
|
||||
for _, s := range sensitiveKeys {
|
||||
if strings.Contains(key, s) {
|
||||
redact = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if redact {
|
||||
out[i] = e[:idx+1] + "***"
|
||||
} else {
|
||||
out[i] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RedactArgs returns a copy of args with values after sensitive flag names masked.
|
||||
// Sensitive flags: --api-key, --api_key, --token, --secret, -k, etc.
|
||||
func RedactArgs(args []string) []string {
|
||||
|
||||
@@ -33,6 +33,22 @@ func CreatePlatform(name string, opts map[string]any) (Platform, error) {
|
||||
return f(opts)
|
||||
}
|
||||
|
||||
func ListRegisteredAgents() []string {
|
||||
names := make([]string, 0, len(agentFactories))
|
||||
for k := range agentFactories {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func ListRegisteredPlatforms() []string {
|
||||
names := make([]string, 0, len(platformFactories))
|
||||
for k := range platformFactories {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func CreateAgent(name string, opts map[string]any) (Agent, error) {
|
||||
f, ok := agentFactories[name]
|
||||
if !ok {
|
||||
|
||||
@@ -45,12 +45,15 @@ export interface ProjectSettingsUpdate {
|
||||
disabled_commands?: string[];
|
||||
work_dir?: string;
|
||||
mode?: string;
|
||||
agent_type?: string;
|
||||
show_context_indicator?: boolean;
|
||||
reply_footer?: boolean;
|
||||
inject_sender?: boolean;
|
||||
platform_allow_from?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const listAgentTypes = () => api.get<{ agents: string[]; platforms: string[] }>('/agents');
|
||||
|
||||
export const listProjects = () => api.get<{ projects: ProjectSummary[] }>('/projects');
|
||||
export const getProject = (name: string) => api.get<ProjectDetail>(`/projects/${name}`);
|
||||
export const updateProject = (name: string, body: ProjectSettingsUpdate) => api.patch(`/projects/${name}`, body);
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
"detail": "Details",
|
||||
"noProjects": "No projects configured",
|
||||
"workDir": "Working directory",
|
||||
"agentType": "Agent type",
|
||||
"agentTypeChangeHint": "Changing agent type requires restart. Incompatible providers will be removed.",
|
||||
"agentMode": "Permission mode",
|
||||
"agentSettings": "Agent",
|
||||
"generalSettings": "General",
|
||||
"showCtxIndicator": "Context indicator",
|
||||
"showCtxIndicatorHint": "Show [ctx: ~N%] suffix on replies",
|
||||
"replyFooter": "Reply footer",
|
||||
"replyFooterHint": "Append model/usage metadata to replies",
|
||||
"injectSender": "Inject sender",
|
||||
"injectSenderHint": "Prepend sender identity to messages sent to agent",
|
||||
"platformAccess": "Platform access control",
|
||||
"deleteTitle": "Delete Project",
|
||||
"deleteConfirm": "Are you sure you want to delete project \"{{name}}\"? This will remove it from the config file.",
|
||||
@@ -135,7 +141,8 @@
|
||||
"runCount": "Run count",
|
||||
"errorCount": "Error count",
|
||||
"skippedBusy": "Skipped (busy)",
|
||||
"lastRun": "Last run"
|
||||
"lastRun": "Last run",
|
||||
"notEnabled": "Heartbeat is not configured for this project. Add [heartbeat] section in config.toml to enable."
|
||||
},
|
||||
"bridge": {
|
||||
"title": "Bridge",
|
||||
@@ -304,8 +311,12 @@
|
||||
"idleTimeout": "Idle timeout (min)",
|
||||
"idleTimeoutHint": "Auto-stop agent after N minutes of inactivity; 0 = disabled",
|
||||
"display": "Display",
|
||||
"thinkingMessages": "Thinking messages",
|
||||
"thinkingMessagesHint": "Show or hide intermediate thinking messages",
|
||||
"thinkingMaxLen": "Thinking max length",
|
||||
"thinkingMaxLenHint": "Max characters for thinking messages; 0 = no truncation",
|
||||
"toolMessages": "Tool progress",
|
||||
"toolMessagesHint": "Show or hide tool progress messages",
|
||||
"toolMaxLen": "Tool max length",
|
||||
"toolMaxLenHint": "Max characters for tool use messages; 0 = no truncation",
|
||||
"streamPreview": "Stream preview",
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
"detail": "Detalles",
|
||||
"noProjects": "No hay proyectos configurados",
|
||||
"workDir": "Directorio de trabajo",
|
||||
"agentType": "Tipo de agente",
|
||||
"agentTypeChangeHint": "Cambiar el tipo de agente requiere reinicio. Los proveedores incompatibles serán eliminados.",
|
||||
"agentMode": "Modo de permisos",
|
||||
"agentSettings": "Agente",
|
||||
"generalSettings": "General",
|
||||
"showCtxIndicator": "Indicador de contexto",
|
||||
"showCtxIndicatorHint": "Mostrar el sufijo [ctx: ~N%] al final de las respuestas",
|
||||
"replyFooter": "Pie de respuesta",
|
||||
"replyFooterHint": "Añadir metadatos de modelo/uso al final de las respuestas",
|
||||
"injectSender": "Inyectar remitente",
|
||||
"injectSenderHint": "Anteponer la identidad del remitente a los mensajes enviados al agente",
|
||||
"platformAccess": "Control de acceso a plataformas",
|
||||
"deleteTitle": "Eliminar proyecto",
|
||||
"deleteConfirm": "¿Está seguro de que desea eliminar el proyecto \"{{name}}\"? Se eliminará del archivo de configuración.",
|
||||
@@ -135,7 +141,8 @@
|
||||
"runCount": "Ejecuciones",
|
||||
"errorCount": "Errores",
|
||||
"skippedBusy": "Omitida (ocupado)",
|
||||
"lastRun": "Última ejecución"
|
||||
"lastRun": "Última ejecución",
|
||||
"notEnabled": "El heartbeat no está configurado para este proyecto. Agregue la sección [heartbeat] en config.toml para habilitarlo."
|
||||
},
|
||||
"bridge": {
|
||||
"title": "Puente",
|
||||
@@ -304,8 +311,12 @@
|
||||
"idleTimeout": "Tiempo de inactividad (min)",
|
||||
"idleTimeoutHint": "Detener agente automáticamente tras N minutos de inactividad; 0 = desactivado",
|
||||
"display": "Visualización",
|
||||
"thinkingMessages": "Mensajes de pensamiento",
|
||||
"thinkingMessagesHint": "Mostrar u ocultar mensajes de proceso de pensamiento",
|
||||
"thinkingMaxLen": "Longitud máx. de pensamiento",
|
||||
"thinkingMaxLenHint": "Caracteres máximos para mensajes de pensamiento; 0 = sin truncar",
|
||||
"toolMessages": "Progreso de herramientas",
|
||||
"toolMessagesHint": "Mostrar u ocultar mensajes de progreso de herramientas",
|
||||
"toolMaxLen": "Longitud máx. de herramientas",
|
||||
"toolMaxLenHint": "Caracteres máximos para mensajes de uso de herramientas; 0 = sin truncar",
|
||||
"streamPreview": "Vista previa en tiempo real",
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
"detail": "詳細",
|
||||
"noProjects": "プロジェクトが設定されていません",
|
||||
"workDir": "作業ディレクトリ",
|
||||
"agentType": "エージェントタイプ",
|
||||
"agentTypeChangeHint": "エージェントタイプの変更には再起動が必要です。互換性のないプロバイダーは削除されます。",
|
||||
"agentMode": "権限モード",
|
||||
"agentSettings": "エージェント",
|
||||
"generalSettings": "一般",
|
||||
"showCtxIndicator": "コンテキスト表示",
|
||||
"showCtxIndicatorHint": "返信の末尾に [ctx: ~N%] を表示",
|
||||
"replyFooter": "返信フッター",
|
||||
"replyFooterHint": "返信の末尾にモデル/使用量のメタ情報を付加",
|
||||
"injectSender": "送信者の注入",
|
||||
"injectSenderHint": "エージェントに送信するメッセージの前に送信者の身元情報を付加",
|
||||
"platformAccess": "プラットフォームのアクセス制御",
|
||||
"deleteTitle": "プロジェクトを削除",
|
||||
"deleteConfirm": "プロジェクト「{{name}}」を削除してもよろしいですか?設定ファイルから削除されます。",
|
||||
@@ -135,7 +141,8 @@
|
||||
"runCount": "実行回数",
|
||||
"errorCount": "エラー回数",
|
||||
"skippedBusy": "スキップ(ビジー)",
|
||||
"lastRun": "最終実行"
|
||||
"lastRun": "最終実行",
|
||||
"notEnabled": "このプロジェクトではハートビートが設定されていません。config.toml に [heartbeat] セクションを追加して有効にしてください。"
|
||||
},
|
||||
"bridge": {
|
||||
"title": "ブリッジ",
|
||||
@@ -304,8 +311,12 @@
|
||||
"idleTimeout": "アイドルタイムアウト(分)",
|
||||
"idleTimeoutHint": "N分間アイドル後にエージェントを自動停止;0 = 無効",
|
||||
"display": "表示",
|
||||
"thinkingMessages": "思考メッセージ",
|
||||
"thinkingMessagesHint": "中間思考プロセスの表示・非表示を切替",
|
||||
"thinkingMaxLen": "思考の最大文字数",
|
||||
"thinkingMaxLenHint": "思考メッセージの最大文字数;0 = 制限なし",
|
||||
"toolMessages": "ツール進捗",
|
||||
"toolMessagesHint": "ツール呼び出し進捗メッセージの表示・非表示を切替",
|
||||
"toolMaxLen": "ツールの最大文字数",
|
||||
"toolMaxLenHint": "ツール使用メッセージの最大文字数;0 = 制限なし",
|
||||
"streamPreview": "ストリームプレビュー",
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
"detail": "詳細資料",
|
||||
"noProjects": "尚未設定專案",
|
||||
"workDir": "工作目錄",
|
||||
"agentType": "Agent 類型",
|
||||
"agentTypeChangeHint": "切換 Agent 類型需要重新啟動,不相容的服務商將被移除。",
|
||||
"agentMode": "權限模式",
|
||||
"agentSettings": "Agent 設定",
|
||||
"generalSettings": "通用設定",
|
||||
"showCtxIndicator": "上下文指示",
|
||||
"showCtxIndicatorHint": "在回覆末尾顯示 [ctx: ~N%]",
|
||||
"replyFooter": "回覆尾部資訊",
|
||||
"replyFooterHint": "在回覆末尾附加模型/用量元資訊",
|
||||
"injectSender": "注入發送者",
|
||||
"injectSenderHint": "在發送給 Agent 的訊息前附加發送者身份資訊",
|
||||
"platformAccess": "平台存取控制",
|
||||
"deleteTitle": "刪除專案",
|
||||
"deleteConfirm": "確定要刪除專案「{{name}}」嗎?這將從設定檔中移除該專案。",
|
||||
@@ -135,7 +141,8 @@
|
||||
"runCount": "執行次數",
|
||||
"errorCount": "錯誤次數",
|
||||
"skippedBusy": "略過(忙碌)",
|
||||
"lastRun": "上次執行"
|
||||
"lastRun": "上次執行",
|
||||
"notEnabled": "該專案未配置心跳功能。請在 config.toml 中添加 [heartbeat] 配置段以啟用。"
|
||||
},
|
||||
"bridge": {
|
||||
"title": "橋接",
|
||||
@@ -304,8 +311,12 @@
|
||||
"idleTimeout": "閒置逾時(分鐘)",
|
||||
"idleTimeoutHint": "Agent 閒置 N 分鐘後自動停止;0 = 不啟用",
|
||||
"display": "顯示",
|
||||
"thinkingMessages": "思考訊息",
|
||||
"thinkingMessagesHint": "顯示或隱藏中間思考過程訊息",
|
||||
"thinkingMaxLen": "思考最大長度",
|
||||
"thinkingMaxLenHint": "思考訊息的最大字元數;0 = 不截斷",
|
||||
"toolMessages": "工具進度",
|
||||
"toolMessagesHint": "顯示或隱藏工具呼叫進度訊息",
|
||||
"toolMaxLen": "工具最大長度",
|
||||
"toolMaxLenHint": "工具使用訊息的最大字元數;0 = 不截斷",
|
||||
"streamPreview": "串流預覽",
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
"detail": "详情",
|
||||
"noProjects": "尚未配置项目",
|
||||
"workDir": "工作目录",
|
||||
"agentType": "Agent 类型",
|
||||
"agentTypeChangeHint": "切换 Agent 类型需要重启,不兼容的服务商将被移除。",
|
||||
"agentMode": "权限模式",
|
||||
"agentSettings": "Agent 配置",
|
||||
"generalSettings": "通用设置",
|
||||
"showCtxIndicator": "上下文指示",
|
||||
"showCtxIndicatorHint": "在回复末尾显示 [ctx: ~N%]",
|
||||
"replyFooter": "回复尾部信息",
|
||||
"replyFooterHint": "在回复末尾附加模型/用量元信息",
|
||||
"injectSender": "注入发送者",
|
||||
"injectSenderHint": "在发送给 Agent 的消息前附加发送者身份信息",
|
||||
"platformAccess": "平台访问控制",
|
||||
"deleteTitle": "删除项目",
|
||||
"deleteConfirm": "确定要删除项目「{{name}}」吗?这将从配置文件中移除该项目。",
|
||||
@@ -135,7 +141,8 @@
|
||||
"runCount": "执行次数",
|
||||
"errorCount": "错误次数",
|
||||
"skippedBusy": "跳过(忙碌)",
|
||||
"lastRun": "上次运行"
|
||||
"lastRun": "上次运行",
|
||||
"notEnabled": "该项目未配置心跳功能。请在 config.toml 中添加 [heartbeat] 配置段以启用。"
|
||||
},
|
||||
"bridge": {
|
||||
"title": "桥接",
|
||||
@@ -304,8 +311,12 @@
|
||||
"idleTimeout": "空闲超时(分钟)",
|
||||
"idleTimeoutHint": "Agent 空闲 N 分钟后自动停止;0 = 不启用",
|
||||
"display": "显示",
|
||||
"thinkingMessages": "思考消息",
|
||||
"thinkingMessagesHint": "显示或隐藏中间思考过程消息",
|
||||
"thinkingMaxLen": "思考最大长度",
|
||||
"thinkingMaxLenHint": "思考消息的最大字符数;0 = 不截断",
|
||||
"toolMessages": "工具进度",
|
||||
"toolMessagesHint": "显示或隐藏工具调用进度消息",
|
||||
"toolMaxLen": "工具最大长度",
|
||||
"toolMaxLenHint": "工具调用消息的最大字符数;0 = 不截断",
|
||||
"streamPreview": "流式预览",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Trash2, Plus, Check, Clock, ExternalLink, Link2,
|
||||
} from 'lucide-react';
|
||||
import { Card, Badge, Button, Input, Modal, EmptyState } from '@/components/ui';
|
||||
import { getProject, updateProject, deleteProject, type ProjectDetail as ProjectDetailType } from '@/api/projects';
|
||||
import { getProject, updateProject, deleteProject, listAgentTypes, type ProjectDetail as ProjectDetailType } from '@/api/projects';
|
||||
import { listProviders, addProvider, removeProvider, activateProvider, type Provider, listGlobalProviders, type GlobalProvider, saveProviderRefs } from '@/api/providers';
|
||||
import { getHeartbeat, pauseHeartbeat, resumeHeartbeat, triggerHeartbeat, setHeartbeatInterval, type HeartbeatStatus } from '@/api/heartbeat';
|
||||
import { restartSystem } from '@/api/status';
|
||||
@@ -55,6 +55,10 @@ export default function ProjectDetail() {
|
||||
const [platformAllowFrom, setPlatformAllowFrom] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Agent type
|
||||
const [agentTypes, setAgentTypes] = useState<string[]>([]);
|
||||
const [selectedAgentType, setSelectedAgentType] = useState('');
|
||||
|
||||
// Global providers & refs
|
||||
const [globalProviders, setGlobalProviders] = useState<GlobalProvider[]>([]);
|
||||
const [providerRefs, setProviderRefs] = useState<string[]>([]);
|
||||
@@ -116,11 +120,12 @@ export default function ProjectDetail() {
|
||||
if (!name) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const [proj, provs, hb, gp] = await Promise.allSettled([
|
||||
const [proj, provs, hb, gp, at] = await Promise.allSettled([
|
||||
getProject(name),
|
||||
listProviders(name),
|
||||
getHeartbeat(name),
|
||||
listGlobalProviders(),
|
||||
listAgentTypes(),
|
||||
]);
|
||||
if (proj.status === 'fulfilled') {
|
||||
setProject(proj.value);
|
||||
@@ -129,6 +134,7 @@ export default function ProjectDetail() {
|
||||
setDisabledCmds(proj.value.settings?.disabled_commands?.join(', ') || '');
|
||||
setWorkDir(proj.value.work_dir || '');
|
||||
setAgentMode(proj.value.agent_mode || 'default');
|
||||
setSelectedAgentType(proj.value.agent_type || '');
|
||||
setShowCtxIndicator(proj.value.show_context_indicator !== false);
|
||||
setReplyFooter(proj.value.reply_footer !== false);
|
||||
setInjectSender(proj.value.inject_sender === true);
|
||||
@@ -143,10 +149,16 @@ export default function ProjectDetail() {
|
||||
setProviders(provs.value.providers || []);
|
||||
setActiveProvider(provs.value.active_provider || '');
|
||||
}
|
||||
if (hb.status === 'fulfilled') setHeartbeatState(hb.value);
|
||||
if (hb.status === 'fulfilled') {
|
||||
const hbVal = hb.value;
|
||||
setHeartbeatState(hbVal?.enabled ? hbVal : null);
|
||||
}
|
||||
if (gp.status === 'fulfilled') {
|
||||
setGlobalProviders(gp.value.providers || []);
|
||||
}
|
||||
if (at.status === 'fulfilled') {
|
||||
setAgentTypes((at.value.agents || []).sort());
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -163,17 +175,23 @@ export default function ProjectDetail() {
|
||||
if (!name) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProject(name, {
|
||||
const agentTypeChanged = project && selectedAgentType !== project.agent_type;
|
||||
const res = await updateProject(name, {
|
||||
language,
|
||||
admin_from: adminFrom,
|
||||
disabled_commands: disabledCmds.split(',').map(s => s.trim()).filter(Boolean),
|
||||
work_dir: workDir,
|
||||
mode: agentMode,
|
||||
...(agentTypeChanged ? { agent_type: selectedAgentType } : {}),
|
||||
show_context_indicator: showCtxIndicator,
|
||||
reply_footer: replyFooter,
|
||||
inject_sender: injectSender,
|
||||
platform_allow_from: platformAllowFrom,
|
||||
});
|
||||
if (res && (res as any).restart_required) {
|
||||
setShowRestartModal(true);
|
||||
return;
|
||||
}
|
||||
await fetchAll();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -273,7 +291,11 @@ export default function ProjectDetail() {
|
||||
{tab === 'providers' && (() => {
|
||||
const globalNames = new Set(globalProviders.map(g => g.name));
|
||||
const isGlobal = (pName: string) => globalNames.has(pName) && providerRefs.includes(pName);
|
||||
const unlinkedGlobals = globalProviders.filter(g => !providerRefs.includes(g.name));
|
||||
const currentAgentType = project?.agent_type || selectedAgentType || '';
|
||||
const unlinkedGlobals = globalProviders.filter(g =>
|
||||
!providerRefs.includes(g.name) &&
|
||||
(!g.agent_types?.length || g.agent_types.includes(currentAgentType))
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -421,7 +443,7 @@ export default function ProjectDetail() {
|
||||
{tab === 'heartbeat' && (
|
||||
<div className="space-y-4">
|
||||
{!heartbeat ? (
|
||||
<EmptyState message={t('common.noData')} />
|
||||
<EmptyState message={t('heartbeat.notEnabled', 'Heartbeat is not configured for this project. Add [heartbeat] section in config.toml to enable.')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
@@ -466,6 +488,24 @@ export default function ProjectDetail() {
|
||||
<Card>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{t('projects.agentSettings', 'Agent')}</h3>
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
{t('projects.agentType', 'Agent type')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedAgentType}
|
||||
onChange={(e) => setSelectedAgentType(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{agentTypes.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
{selectedAgentType && !agentTypes.includes(selectedAgentType) && (
|
||||
<option value={selectedAgentType}>{selectedAgentType}</option>
|
||||
)}
|
||||
</select>
|
||||
{selectedAgentType !== project.agent_type && (
|
||||
<p className="text-[11px] text-amber-500 mt-1">{t('projects.agentTypeChangeHint', 'Changing agent type requires restart. Incompatible providers will be removed.')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Input label={t('projects.workDir', 'Working directory')} value={workDir} onChange={(e) => setWorkDir(e.target.value)} placeholder="/path/to/project" />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
@@ -647,10 +687,10 @@ export default function ProjectDetail() {
|
||||
{t('setup.restartHint', 'Restart the service for the new platform to take effect.')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowRestartModal(false); fetchAll(); }}>
|
||||
<Button variant="secondary" onClick={() => { setShowRestartModal(false); setTimeout(fetchAll, 300); }}>
|
||||
{t('setup.later', 'Later')}
|
||||
</Button>
|
||||
<Button onClick={async () => { await restartSystem(); setShowRestartModal(false); fetchAll(); }}>
|
||||
<Button onClick={async () => { await restartSystem(); setShowRestartModal(false); await waitForService(8000); await fetchAll(); }}>
|
||||
{t('setup.restartNow', 'Restart now')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user