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:
Claude committed 2026-04-19 00:10:29 +08:00
1 parent c4279d142e
commit d02a5f1370
19 files changed
+1049 -18

No files matched your search

+18 -2
View File
@@ -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
}
+108
View File
@@ -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")
}
+15
View File
@@ -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()
+355
View File
@@ -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())
}
}
})
}
}
+1
View File
@@ -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,
+40
View File
@@ -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
View File
@@ -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 != "" {
+5
View File
@@ -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
View File
@@ -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
}
+30
View File
@@ -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 {
+16
View File
@@ -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 {
+3
View File
@@ -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);
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "ストリームプレビュー",
+12 -1
View File
@@ -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": "串流預覽",
+12 -1
View File
@@ -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": "流式预览",
+48 -8
View File
@@ -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>