feat: add executor parity safeguards (#2704)
This commit is contained in:
@@ -461,6 +461,32 @@ type RemoteAuthMethod struct {
|
||||
SetupScript string `json:"setup_script,omitempty"`
|
||||
}
|
||||
|
||||
// PortableConfigAgent is an optional capability for agents that have a small,
|
||||
// explicitly allowlisted set of user configuration files that can be copied
|
||||
// into an isolated executor. It is intentionally separate from RemoteAuth.
|
||||
type PortableConfigAgent interface {
|
||||
PortableConfig() *PortableConfig
|
||||
}
|
||||
|
||||
// PortableConfig declares safe configuration bundles for an agent.
|
||||
type PortableConfig struct {
|
||||
Bundles []PortableConfigBundle `json:"bundles"`
|
||||
}
|
||||
|
||||
// PortableConfigBundle is one stable, user-selectable configuration unit.
|
||||
type PortableConfigBundle struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Files []PortableConfigFile `json:"files"`
|
||||
}
|
||||
|
||||
// PortableConfigFile maps an OS-specific host path relative to the host home
|
||||
// directory to a relative path below the executor user's home directory.
|
||||
type PortableConfigFile struct {
|
||||
SourcePaths map[string]string `json:"source_paths"`
|
||||
TargetPath string `json:"target_path"`
|
||||
}
|
||||
|
||||
// Command is a domain value type representing a CLI command with arguments.
|
||||
// Serialize to []string only at system boundaries (process exec, Docker API, JSON DTOs).
|
||||
type Command struct {
|
||||
|
||||
@@ -136,6 +136,7 @@ func (a *ClaudeACP) Runtime() *RuntimeConfig {
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.claude",
|
||||
SessionDirTarget: "/root/.claude",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -161,6 +162,22 @@ chmod 600 "${HOME}/.claude.json"`,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClaudeACP) PortableConfig() *PortableConfig {
|
||||
return &PortableConfig{Bundles: []PortableConfigBundle{
|
||||
{
|
||||
ID: "claude.settings",
|
||||
Label: "Copy Claude settings",
|
||||
Files: []PortableConfigFile{
|
||||
{SourcePaths: map[string]string{
|
||||
"darwin": ".claude/settings.json",
|
||||
"linux": ".claude/settings.json",
|
||||
"windows": ".claude/settings.json",
|
||||
}, TargetPath: ".claude/settings.json"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Verified: `claude --help` documents `claude auth login` as the dedicated
|
||||
// sign-in subcommand (also `claude auth logout` / `claude auth status`).
|
||||
// Source: https://code.claude.com/docs/en/cli-reference
|
||||
|
||||
@@ -140,8 +140,8 @@ func (a *CodexACP) RemoteAuth() *RemoteAuth {
|
||||
Type: "files",
|
||||
Label: "Copy auth files",
|
||||
SourceFiles: map[string][]string{
|
||||
"darwin": {".codex/auth.json", ".codex/config.toml"},
|
||||
"linux": {".codex/auth.json", ".codex/config.toml"},
|
||||
"darwin": {".codex/auth.json"},
|
||||
"linux": {".codex/auth.json"},
|
||||
},
|
||||
TargetRelDir: ".codex",
|
||||
},
|
||||
@@ -153,6 +153,22 @@ func (a *CodexACP) RemoteAuth() *RemoteAuth {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CodexACP) PortableConfig() *PortableConfig {
|
||||
return &PortableConfig{Bundles: []PortableConfigBundle{
|
||||
{
|
||||
ID: "codex.config",
|
||||
Label: "Copy Codex configuration",
|
||||
Files: []PortableConfigFile{
|
||||
{SourcePaths: map[string]string{
|
||||
"darwin": ".codex/config.toml",
|
||||
"linux": ".codex/config.toml",
|
||||
"windows": ".codex/config.toml",
|
||||
}, TargetPath: ".codex/config.toml"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Verified against `codex --help`: `codex login --device-auth` is the
|
||||
// dedicated sign-in subcommand. Device-auth prints a code + URL that works
|
||||
// even when the kandev process can't open a browser (containers, SSH,
|
||||
|
||||
@@ -173,15 +173,55 @@ func (a *MockAgent) Runtime() *RuntimeConfig {
|
||||
ProjectSkillDir: DefaultProjectSkillDir,
|
||||
UserSkillDir: ".mock-agent/skills",
|
||||
SessionConfig: SessionConfig{
|
||||
CanRecover: &canRecover,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.mock-agent",
|
||||
SessionDirTarget: "/root/.mock-agent",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteAuth returns a non-nil empty spec so the mock agent counts as a
|
||||
// remote-capable agent (it's runnable inside Docker for E2E tests) but
|
||||
// requires no credentials. The empty methods list signals "no auth needed".
|
||||
func (a *MockAgent) RemoteAuth() *RemoteAuth { return &RemoteAuth{} }
|
||||
// RemoteAuth returns a Codex-shaped auth spec for the mock Codex alias so the
|
||||
// settings E2E can prove auth and configuration remain independent. The base
|
||||
// mock agent still requires no credentials.
|
||||
func (a *MockAgent) RemoteAuth() *RemoteAuth {
|
||||
if a.ID() != "codex-acp" {
|
||||
return &RemoteAuth{}
|
||||
}
|
||||
return &RemoteAuth{Methods: []RemoteAuthMethod{
|
||||
{
|
||||
Type: "files",
|
||||
Label: "Copy auth files",
|
||||
SourceFiles: map[string][]string{
|
||||
"darwin": {".codex/auth.json"},
|
||||
"linux": {".codex/auth.json"},
|
||||
},
|
||||
TargetRelDir: ".codex",
|
||||
},
|
||||
{Type: "env", EnvVar: "OPENAI_API_KEY"},
|
||||
}}
|
||||
}
|
||||
|
||||
// PortableConfig exposes one disposable settings file so the Docker E2E
|
||||
// agent can exercise the same isolated-home transfer path as a real ACP
|
||||
// provider without requiring provider credentials.
|
||||
func (a *MockAgent) PortableConfig() *PortableConfig {
|
||||
bundleID := "mock.settings"
|
||||
if a.ID() != mockAgentDefaultID {
|
||||
bundleID = a.ID() + ".settings"
|
||||
}
|
||||
return &PortableConfig{Bundles: []PortableConfigBundle{
|
||||
{
|
||||
ID: bundleID,
|
||||
Label: "Copy mock-agent settings",
|
||||
Files: []PortableConfigFile{
|
||||
{
|
||||
SourcePaths: map[string]string{"": ".mock-agent/settings.json"},
|
||||
TargetPath: ".mock-agent/settings.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// InstallScript returns a deterministic short-lived shell script so e2e tests
|
||||
// can exercise the install streaming endpoint without depending on npm.
|
||||
|
||||
@@ -114,7 +114,11 @@ func (a *OpenCodeACP) Runtime() *RuntimeConfig {
|
||||
NativeSessionResume: true,
|
||||
NewSessionOnWorkspaceRebind: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.opencode",
|
||||
// Auth lives under .local/share/opencode and configuration under
|
||||
// .config/opencode. Mount the isolated executor home so both trees
|
||||
// remain visible without mounting the host home.
|
||||
SessionDirTemplate: "{home}",
|
||||
SessionDirTarget: "/root",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -135,6 +139,27 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OpenCodeACP) PortableConfig() *PortableConfig {
|
||||
return &PortableConfig{Bundles: []PortableConfigBundle{
|
||||
{
|
||||
ID: "opencode.config",
|
||||
Label: "Copy OpenCode configuration",
|
||||
Files: []PortableConfigFile{
|
||||
{SourcePaths: map[string]string{
|
||||
"darwin": ".config/opencode/opencode.json",
|
||||
"linux": ".config/opencode/opencode.json",
|
||||
"windows": ".config/opencode/opencode.json",
|
||||
}, TargetPath: ".config/opencode/opencode.json"},
|
||||
{SourcePaths: map[string]string{
|
||||
"darwin": ".config/opencode/opencode.jsonc",
|
||||
"linux": ".config/opencode/opencode.jsonc",
|
||||
"windows": ".config/opencode/opencode.jsonc",
|
||||
}, TargetPath: ".config/opencode/opencode.jsonc"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func (a *OpenCodeACP) InstallScript() string {
|
||||
return "npm install -g " + opencodeACPPackage
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package agents
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInitialPortableConfigDeclarations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
agent Agent
|
||||
bundleID string
|
||||
filePaths []string
|
||||
}{
|
||||
{name: "claude", agent: NewClaudeACP(), bundleID: "claude.settings", filePaths: []string{".claude/settings.json"}},
|
||||
{name: "codex", agent: NewCodexACP(), bundleID: "codex.config", filePaths: []string{".codex/config.toml"}},
|
||||
{name: "opencode", agent: NewOpenCodeACP(), bundleID: "opencode.config", filePaths: []string{".config/opencode/opencode.json", ".config/opencode/opencode.jsonc"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := tt.agent.(PortableConfigAgent).PortableConfig()
|
||||
if config == nil || len(config.Bundles) != 1 {
|
||||
t.Fatalf("portable config = %+v, want one bundle", config)
|
||||
}
|
||||
bundle := config.Bundles[0]
|
||||
if bundle.ID != tt.bundleID {
|
||||
t.Fatalf("bundle ID = %q, want %q", bundle.ID, tt.bundleID)
|
||||
}
|
||||
if len(bundle.Files) != len(tt.filePaths) {
|
||||
t.Fatalf("bundle files = %+v, want %v", bundle.Files, tt.filePaths)
|
||||
}
|
||||
for index, file := range bundle.Files {
|
||||
if file.TargetPath != tt.filePaths[index] {
|
||||
t.Errorf("file %d target = %q, want %q", index, file.TargetPath, tt.filePaths[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRemoteAuthContainsAuthOnly(t *testing.T) {
|
||||
auth := NewCodexACP().RemoteAuth()
|
||||
if auth == nil || len(auth.Methods) == 0 {
|
||||
t.Fatal("Codex RemoteAuth must declare auth")
|
||||
}
|
||||
for _, method := range auth.Methods {
|
||||
for _, paths := range method.SourceFiles {
|
||||
for _, path := range paths {
|
||||
if path == ".codex/config.toml" {
|
||||
t.Fatal("Codex config.toml must be independent from authentication")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockPortableConfigUsesUniqueBundleIDsForAliases(t *testing.T) {
|
||||
defaultBundle := NewMockAgent().PortableConfig().Bundles[0]
|
||||
aliasBundle := NewMockAgentWithID("codex-acp", "Mock Codex", "Mock Codex").PortableConfig().Bundles[0]
|
||||
|
||||
if defaultBundle.ID != "mock.settings" {
|
||||
t.Fatalf("default mock bundle ID = %q, want mock.settings", defaultBundle.ID)
|
||||
}
|
||||
if aliasBundle.ID != "codex-acp.settings" {
|
||||
t.Fatalf("aliased mock bundle ID = %q, want codex-acp.settings", aliasBundle.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package remoteconfig owns the backend allowlist for small agent
|
||||
// configuration files that may be copied into isolated executors.
|
||||
package remoteconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
)
|
||||
|
||||
// File is safe metadata for one declared configuration file. It contains no
|
||||
// file contents and never contains an absolute host path.
|
||||
type File struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
TargetPath string `json:"target_path"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// Bundle is one stable, selectable configuration bundle.
|
||||
type Bundle struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Label string `json:"label"`
|
||||
Files []File `json:"files"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// Catalog is the host-aware catalog plus an internal lookup for transfer.
|
||||
type Catalog struct {
|
||||
Bundles []Bundle
|
||||
bundlesByID map[string]Bundle
|
||||
}
|
||||
|
||||
// BuildCatalog creates a catalog for the current host home and OS.
|
||||
func BuildCatalog(enabledAgents []agents.Agent) Catalog {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return BuildCatalogForHost(enabledAgents, runtime.GOOS, homeDir)
|
||||
}
|
||||
|
||||
// BuildCatalogForHost creates a catalog for an explicit host OS and home.
|
||||
// Only agents with a PortableConfig declaration contribute bundles.
|
||||
func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string) Catalog {
|
||||
bundles := make([]Bundle, 0)
|
||||
byID := make(map[string]Bundle)
|
||||
for _, ag := range enabledAgents {
|
||||
capability, ok := ag.(agents.PortableConfigAgent)
|
||||
if !ok || capability.PortableConfig() == nil {
|
||||
continue
|
||||
}
|
||||
config := capability.PortableConfig()
|
||||
for _, declared := range config.Bundles {
|
||||
if declared.ID == "" || len(declared.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := byID[declared.ID]; duplicate {
|
||||
continue
|
||||
}
|
||||
bundle := Bundle{
|
||||
ID: declared.ID,
|
||||
AgentID: ag.ID(),
|
||||
DisplayName: ag.DisplayName(),
|
||||
Label: declared.Label,
|
||||
Files: make([]File, 0, len(declared.Files)),
|
||||
}
|
||||
for _, declaredFile := range declared.Files {
|
||||
sourcePath := sourcePathForOS(declaredFile.SourcePaths, currentOS)
|
||||
if sourcePath == "" || declaredFile.TargetPath == "" {
|
||||
continue
|
||||
}
|
||||
available := localRegularFileExists(homeDir, sourcePath)
|
||||
bundle.Files = append(bundle.Files, File{
|
||||
SourcePath: sourcePath,
|
||||
TargetPath: declaredFile.TargetPath,
|
||||
Available: available,
|
||||
})
|
||||
bundle.Available = bundle.Available || available
|
||||
}
|
||||
if len(bundle.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
bundles = append(bundles, bundle)
|
||||
byID[bundle.ID] = bundle
|
||||
}
|
||||
}
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].ID < bundles[j].ID })
|
||||
return Catalog{Bundles: bundles, bundlesByID: byID}
|
||||
}
|
||||
|
||||
// FindBundle returns a declared bundle by its stable ID.
|
||||
func (c Catalog) FindBundle(id string) (Bundle, bool) {
|
||||
bundle, ok := c.bundlesByID[id]
|
||||
return bundle, ok
|
||||
}
|
||||
|
||||
func sourcePathForOS(paths map[string]string, currentOS string) string {
|
||||
if path := paths[currentOS]; path != "" {
|
||||
return path
|
||||
}
|
||||
if path := paths["linux"]; path != "" {
|
||||
return path
|
||||
}
|
||||
return paths[""]
|
||||
}
|
||||
|
||||
func localRegularFileExists(homeDir, relativePath string) bool {
|
||||
if homeDir == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Lstat(filepath.Join(homeDir, relativePath))
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package remoteconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
)
|
||||
|
||||
func TestBuildCatalogForHostUsesStableBundleIDsAndAvailability(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"private\""), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := BuildCatalogForHost([]agents.Agent{agents.NewOpenCodeACP(), agents.NewCodexACP(), agents.NewClaudeACP()}, "linux", home)
|
||||
second := BuildCatalogForHost([]agents.Agent{agents.NewClaudeACP(), agents.NewCodexACP(), agents.NewOpenCodeACP()}, "linux", home)
|
||||
|
||||
firstIDs := bundleIDs(first.Bundles)
|
||||
secondIDs := bundleIDs(second.Bundles)
|
||||
slices.Sort(firstIDs)
|
||||
slices.Sort(secondIDs)
|
||||
if !slices.Equal(firstIDs, secondIDs) {
|
||||
t.Fatalf("bundle IDs depend on declaration order: %v vs %v", firstIDs, secondIDs)
|
||||
}
|
||||
|
||||
codex, ok := first.FindBundle("codex.config")
|
||||
if !ok || !codex.Available {
|
||||
t.Fatalf("codex bundle = %+v, want available stable codex.config bundle", codex)
|
||||
}
|
||||
if len(codex.Files) != 1 || codex.Files[0].SourcePath != ".codex/config.toml" {
|
||||
t.Fatalf("codex bundle files = %+v, want config.toml only", codex.Files)
|
||||
}
|
||||
|
||||
claude, ok := first.FindBundle("claude.settings")
|
||||
if !ok || claude.Available {
|
||||
t.Fatalf("claude bundle = %+v, want unavailable without settings.json", claude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCatalogForHostDoesNotExposeFileData(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := "host-only-secret"
|
||||
if err := os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte(secret), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
catalog := BuildCatalogForHost([]agents.Agent{agents.NewCodexACP()}, "linux", home)
|
||||
encoded, err := json.Marshal(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Fatalf("catalog leaked file data: %s", encoded)
|
||||
}
|
||||
if strings.Contains(string(encoded), home) {
|
||||
t.Fatalf("catalog leaked host home path: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCatalogForHostSkipsAgentsWithoutDeclarations(t *testing.T) {
|
||||
catalog := BuildCatalogForHost([]agents.Agent{
|
||||
agents.NewTUIAgent(agents.TUIAgentConfig{
|
||||
AgentID: "plain-tui",
|
||||
AgentName: "Plain TUI",
|
||||
Command: "plain-tui",
|
||||
Desc: "test agent without portable configuration",
|
||||
}),
|
||||
}, "linux", "")
|
||||
if len(catalog.Bundles) != 0 {
|
||||
t.Fatalf("bundles = %+v, want empty catalog", catalog.Bundles)
|
||||
}
|
||||
}
|
||||
|
||||
func bundleIDs(bundles []Bundle) []string {
|
||||
ids := make([]string, 0, len(bundles))
|
||||
for _, bundle := range bundles {
|
||||
ids = append(ids, bundle.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -73,9 +73,10 @@ func (c *Client) Initialize(ctx context.Context, clientName, clientVersion strin
|
||||
|
||||
// NewSessionResponse from agentctl
|
||||
type NewSessionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ModelState *streams.SessionModelState `json:"model_state,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// createSessionRequest sends a session creation request and parses the response.
|
||||
@@ -86,6 +87,7 @@ func (c *Client) createSessionRequest(ctx context.Context, action, cwd string, m
|
||||
McpServers []types.McpServer `json:"mcp_servers,omitempty"`
|
||||
}{Cwd: cwd, McpServers: mcpServers}
|
||||
|
||||
c.setLastSessionModelState(nil)
|
||||
resp, err := c.sendStreamRequest(ctx, action, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s request failed: %w", action, err)
|
||||
@@ -106,6 +108,7 @@ func (c *Client) createSessionRequest(ctx context.Context, action, cwd string, m
|
||||
if !result.Success {
|
||||
return "", fmt.Errorf("%s failed: %s", action, result.Error)
|
||||
}
|
||||
c.setLastSessionModelState(result.ModelState)
|
||||
return result.SessionID, nil
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [
|
||||
McpServers []types.McpServer `json:"mcp_servers,omitempty"`
|
||||
}{SessionID: sessionID, McpServers: mcpServers}
|
||||
|
||||
c.setLastSessionModelState(nil)
|
||||
resp, err := c.sendStreamRequest(ctx, "agent.session.load", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load session request failed: %w", err)
|
||||
@@ -143,8 +147,9 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
ModelState *streams.SessionModelState `json:"model_state,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := resp.ParsePayload(&result); err != nil {
|
||||
return fmt.Errorf("failed to parse load session response: %w", err)
|
||||
@@ -152,6 +157,7 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [
|
||||
if !result.Success {
|
||||
return fmt.Errorf("load session failed: %s", result.Error)
|
||||
}
|
||||
c.setLastSessionModelState(result.ModelState)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -52,8 +53,12 @@ func okResponse(payload map[string]any) func(ws.Message) *ws.Message {
|
||||
}
|
||||
|
||||
func TestResetSession_UsesResetActionAndReturnsNewSessionID(t *testing.T) {
|
||||
modelState := &streams.SessionModelState{
|
||||
CurrentModelID: "mock-fast",
|
||||
Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}},
|
||||
}
|
||||
c, captured := captureStreamRequest(t, okResponse(map[string]any{
|
||||
"success": true, "session_id": "sess-reset-1",
|
||||
"success": true, "session_id": "sess-reset-1", "model_state": modelState,
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -67,6 +72,10 @@ func TestResetSession_UsesResetActionAndReturnsNewSessionID(t *testing.T) {
|
||||
if sessionID != "sess-reset-1" {
|
||||
t.Errorf("session ID = %q, want sess-reset-1", sessionID)
|
||||
}
|
||||
state := c.GetLastSessionModelState()
|
||||
if state == nil || state.CurrentModelID != "mock-fast" || len(state.Models) != 2 {
|
||||
t.Fatalf("model state = %+v, want the synchronous reset catalog", state)
|
||||
}
|
||||
|
||||
sent := captured()
|
||||
// Reset must NOT reuse agent.session.new — that restarts the subprocess
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kandev/kandev/internal/agentctl/tracing"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/mcp/plugintools"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -59,6 +60,49 @@ type Client struct {
|
||||
pendingRequests map[string]chan *ws.Message
|
||||
pendingRequestConns map[string]*websocket.Conn
|
||||
pendingMu sync.Mutex
|
||||
|
||||
// lastSessionModelState is populated synchronously by session/new,
|
||||
// session/reset, or session/load responses. Lifecycle policy evaluation can
|
||||
// use it before the corresponding session_models event reaches its handler.
|
||||
lastSessionModelState *streams.SessionModelState
|
||||
}
|
||||
|
||||
func (c *Client) setLastSessionModelState(state *streams.SessionModelState) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lastSessionModelState = cloneSessionModelState(state)
|
||||
}
|
||||
|
||||
// GetLastSessionModelState returns the model catalog included in the most
|
||||
// recent session creation or load response.
|
||||
func (c *Client) GetLastSessionModelState() *streams.SessionModelState {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return cloneSessionModelState(c.lastSessionModelState)
|
||||
}
|
||||
|
||||
func cloneSessionModelState(state *streams.SessionModelState) *streams.SessionModelState {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := &streams.SessionModelState{
|
||||
CurrentModelID: state.CurrentModelID,
|
||||
Models: append([]streams.SessionModelInfo(nil), state.Models...),
|
||||
ConfigOptions: append([]streams.ConfigOption(nil), state.ConfigOptions...),
|
||||
}
|
||||
for i, model := range cloned.Models {
|
||||
if model.Meta == nil {
|
||||
continue
|
||||
}
|
||||
cloned.Models[i].Meta = make(map[string]any, len(model.Meta))
|
||||
for key, value := range model.Meta {
|
||||
cloned.Models[i].Meta[key] = value
|
||||
}
|
||||
}
|
||||
for i, option := range cloned.ConfigOptions {
|
||||
cloned.ConfigOptions[i].Options = append([]streams.ConfigOptionValue(nil), option.Options...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// ClientOption configures optional Client settings.
|
||||
|
||||
@@ -64,6 +64,9 @@ func SessionDirHostPath(kandevHomeDir, instanceID, sessionDirTemplate string) st
|
||||
return ""
|
||||
}
|
||||
rel := strings.TrimPrefix(sessionDirTemplate, "{home}/")
|
||||
if sessionDirTemplate == "{home}" {
|
||||
rel = "."
|
||||
}
|
||||
return filepath.Join(root, rel)
|
||||
}
|
||||
|
||||
@@ -78,14 +81,7 @@ type localFileUploader struct {
|
||||
}
|
||||
|
||||
func (u localFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error {
|
||||
cleanPath, err := containedPath(u.root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(cleanPath), err)
|
||||
}
|
||||
return os.WriteFile(cleanPath, data, mode)
|
||||
return writeFileWithinRoot(u.root, path, data, mode)
|
||||
}
|
||||
|
||||
// containedPath returns a cleaned absolute path guaranteed to be inside root,
|
||||
@@ -108,8 +104,8 @@ func containedPath(root, path string) (string, error) {
|
||||
}
|
||||
|
||||
// SeedAgentSessionDir copies the agent's RemoteAuth.SourceFiles from the host
|
||||
// home into a per-container session dir, populating just the auth-relevant
|
||||
// files (typically auth.json + config.toml). Stale per-host state — codex's
|
||||
// home into a per-container session dir. Optional configuration bundles are
|
||||
// copied only when selectedIDs is provided. Stale per-host state — codex's
|
||||
// state.db with absolute macOS paths, sessions/ subdirectories, lock files —
|
||||
// is intentionally NOT copied; the agent rebuilds them inside the container
|
||||
// with container-local Linux paths.
|
||||
@@ -122,6 +118,22 @@ func SeedAgentSessionDir(
|
||||
ag agents.Agent,
|
||||
instanceSessionRoot string,
|
||||
log *logger.Logger,
|
||||
selectedIDs ...[]string,
|
||||
) error {
|
||||
var selected []string
|
||||
if len(selectedIDs) > 0 {
|
||||
selected = selectedIDs[0]
|
||||
}
|
||||
return seedAgentSessionDir(ctx, ag, instanceSessionRoot, log, selected, nil)
|
||||
}
|
||||
|
||||
func seedAgentSessionDir(
|
||||
ctx context.Context,
|
||||
ag agents.Agent,
|
||||
instanceSessionRoot string,
|
||||
log *logger.Logger,
|
||||
selectedIDs []string,
|
||||
onWarnings func([]PortableConfigWarning),
|
||||
) error {
|
||||
if ag == nil || instanceSessionRoot == "" {
|
||||
return nil
|
||||
@@ -134,33 +146,52 @@ func SeedAgentSessionDir(
|
||||
return fmt.Errorf("mkdir %s: %w", instanceSessionRoot, err)
|
||||
}
|
||||
|
||||
authErr := seedAgentAuthFiles(ctx, ag, instanceSessionRoot, log)
|
||||
if authErr != nil && log != nil {
|
||||
log.Warn("failed to seed agent authentication files; continuing with configuration bundles", zap.Error(authErr))
|
||||
}
|
||||
if len(selectedIDs) > 0 {
|
||||
warnings := UploadPortableConfigBundles(
|
||||
ctx, localFileUploader{root: instanceSessionRoot}, ag, selectedIDs, instanceSessionRoot, log,
|
||||
)
|
||||
if onWarnings != nil {
|
||||
onWarnings(warnings)
|
||||
}
|
||||
}
|
||||
return authErr
|
||||
}
|
||||
|
||||
func seedAgentAuthFiles(ctx context.Context, ag agents.Agent, instanceSessionRoot string, log *logger.Logger) error {
|
||||
auth := ag.RemoteAuth()
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
hostOS := runtime.GOOS
|
||||
methods := make([]remoteauth.Method, 0, len(auth.Methods))
|
||||
for _, m := range auth.Methods {
|
||||
if m.Type != authMethodTypeFiles || len(m.SourceFiles) == 0 {
|
||||
methods := authMethodsForHost(auth.Methods, runtime.GOOS)
|
||||
if len(methods) == 0 {
|
||||
return nil
|
||||
}
|
||||
return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log)
|
||||
}
|
||||
|
||||
func authMethodsForHost(methods []agents.RemoteAuthMethod, hostOS string) []remoteauth.Method {
|
||||
selected := make([]remoteauth.Method, 0, len(methods))
|
||||
for _, method := range methods {
|
||||
if method.Type != authMethodTypeFiles || len(method.SourceFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
// Resolve the OS-specific source list into a flat one keyed by host
|
||||
// OS, falling back to "linux" for cross-platform agents.
|
||||
srcs := selectAuthSourceFiles(m, hostOS)
|
||||
if len(srcs) == 0 {
|
||||
sources := selectAuthSourceFiles(method, hostOS)
|
||||
if len(sources) == 0 {
|
||||
continue
|
||||
}
|
||||
methods = append(methods, remoteauth.Method{
|
||||
Type: m.Type,
|
||||
SourceFiles: srcs,
|
||||
TargetRelDir: m.TargetRelDir,
|
||||
selected = append(selected, remoteauth.Method{
|
||||
Type: method.Type,
|
||||
SourceFiles: sources,
|
||||
TargetRelDir: method.TargetRelDir,
|
||||
})
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log)
|
||||
return selected
|
||||
}
|
||||
|
||||
// selectAuthSourceFiles picks the SourceFiles list to use for the host OS,
|
||||
|
||||
@@ -47,7 +47,7 @@ func writeFile(t *testing.T, root, rel string, contents []byte) {
|
||||
}
|
||||
|
||||
// TestSeedAgentSessionDir_OnlyCopiesAuthFiles is the load-bearing check for
|
||||
// the codex bug: the seeder must copy auth.json/config.toml from the host
|
||||
// the codex boundary: the seeder must copy auth.json from the host
|
||||
// home into the per-container session dir but must NOT bring along state.db,
|
||||
// sessions/, or other host-state caches that contain absolute host paths.
|
||||
func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) {
|
||||
@@ -69,14 +69,14 @@ func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) {
|
||||
|
||||
dotdir := filepath.Join(instanceRoot, ".codex")
|
||||
|
||||
mustExist := []string{"auth.json", "config.toml"}
|
||||
mustExist := []string{"auth.json"}
|
||||
for _, name := range mustExist {
|
||||
if _, err := os.Stat(filepath.Join(dotdir, name)); err != nil {
|
||||
t.Fatalf("expected %s in session dir: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotExist := []string{"state.db", "state.db-wal", "junk.txt", "sessions"}
|
||||
mustNotExist := []string{"config.toml", "state.db", "state.db-wal", "junk.txt", "sessions"}
|
||||
for _, name := range mustNotExist {
|
||||
if _, err := os.Stat(filepath.Join(dotdir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected %s leaked into session dir (err=%v)", name, err)
|
||||
@@ -84,6 +84,30 @@ func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedAgentSessionDir_CopiesSelectedPortableConfig(t *testing.T) {
|
||||
hostHome := seedTestHostHome(t)
|
||||
writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"abc"}`))
|
||||
writeFile(t, hostHome, ".codex/config.toml", []byte(`model = "gpt"`))
|
||||
|
||||
kandevHome := t.TempDir()
|
||||
instanceRoot := InstanceSessionRoot(kandevHome, "selected-config")
|
||||
if err := SeedAgentSessionDir(
|
||||
context.Background(), agents.NewCodexACP(), instanceRoot, newSeederTestLogger(t), []string{"codex.config"},
|
||||
); err != nil {
|
||||
t.Fatalf("SeedAgentSessionDir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(instanceRoot, ".codex", "config.toml")); err != nil {
|
||||
t.Fatalf("selected config missing: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(instanceRoot, ".codex", "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("selected config mode = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedAgentSessionDir_PerInstanceUnique ensures two containers running
|
||||
// the same agent get independent session dirs. Without this the codex state
|
||||
// DB from one task would clobber another's, defeating the whole isolation
|
||||
@@ -240,6 +264,7 @@ func TestDockerStopInstance_PreservesSessionDirOnPlainStop(t *testing.T) {
|
||||
{reason: "stopped via API", expectExists: true},
|
||||
{reason: "agent crashed", expectExists: true},
|
||||
{reason: "user requested", expectExists: true},
|
||||
{reason: stopReasonStaleExecutionCleanup, expectExists: true},
|
||||
{reason: "task archived", expectExists: false},
|
||||
{reason: "task deleted", expectExists: false},
|
||||
{reason: "session archived", expectExists: false},
|
||||
|
||||
@@ -202,6 +202,10 @@ type AgentStreamEventData struct {
|
||||
// model was unavailable.
|
||||
FallbackModel string `json:"fallback_model,omitempty"`
|
||||
|
||||
// ModelSelectionWarning explains an executor-authoritative default or
|
||||
// explicit fallback decision.
|
||||
ModelSelectionWarning *streams.ModelSelectionWarning `json:"model_selection_warning,omitempty"`
|
||||
|
||||
// Session info (from "session_info" event)
|
||||
SessionTitle string `json:"session_title,omitempty"`
|
||||
SessionUpdatedAt string `json:"session_updated_at,omitempty"`
|
||||
@@ -566,6 +570,21 @@ func (p SessionModelFallbackEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// SessionModelSelectionWarningEventPayload is the provider-neutral live
|
||||
// payload for an executor-authoritative model decision.
|
||||
type SessionModelSelectionWarningEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Warning streams.ModelSelectionWarning `json:"warning"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GetSessionID returns the task-session ID used by the gateway for routing.
|
||||
func (p SessionModelSelectionWarningEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// SessionModelsSnapshot is the persisted provider-derived state needed to
|
||||
// hydrate the task model selector before live session events reconnect.
|
||||
type SessionModelsSnapshot struct {
|
||||
|
||||
@@ -207,6 +207,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
|
||||
AuthMethods: event.AuthMethods,
|
||||
CurrentModelID: event.CurrentModelID,
|
||||
FallbackModel: event.FallbackModel,
|
||||
ModelSelectionWarning: event.ModelSelectionWarning,
|
||||
SessionModels: event.SessionModels,
|
||||
ConfigOptions: event.ConfigOptions,
|
||||
ConfigBaselineCandidate: event.ConfigBaselineCandidate,
|
||||
|
||||
@@ -153,6 +153,8 @@ const (
|
||||
MetadataKeyContributionDestinations = "contribution_destinations"
|
||||
MetadataKeyIsRemote = "is_remote"
|
||||
MetadataKeyRemoteAuthHome = "remote_auth_target_home"
|
||||
MetadataKeyAgentConfigBundles = "agent_config_bundles"
|
||||
MetadataKeyExecutorProfileID = "executor_profile_id"
|
||||
MetadataKeyGitUserName = "git_user_name"
|
||||
MetadataKeyGitUserEmail = "git_user_email"
|
||||
MetadataKeyImageTagOverride = "image_tag_override"
|
||||
@@ -230,13 +232,14 @@ var persistentMetadataKeys = map[string]bool{
|
||||
MetadataKeyCleanupScript: true,
|
||||
MetadataKeyRepoSetupScript: true,
|
||||
MetadataKeyRemoteAuthHome: true,
|
||||
MetadataKeyAgentConfigBundles: true,
|
||||
MetadataKeyGitUserName: true,
|
||||
MetadataKeyGitUserEmail: true,
|
||||
"remote_credentials": true,
|
||||
"remote_auth_secrets": true,
|
||||
"executor_mcp_policy": true,
|
||||
"sprites_network_policy_rules": true,
|
||||
"executor_profile_id": true,
|
||||
MetadataKeyExecutorProfileID: true,
|
||||
MetadataKeyImageTagOverride: true,
|
||||
MetadataKeyContainerID: true,
|
||||
MetadataKeyWorktreeBranch: true,
|
||||
|
||||
@@ -235,8 +235,8 @@ func (r *DockerExecutor) tryReconnect(ctx context.Context, dockerClient *docker.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// seedSessionDir copies the agent's auth files (auth.json / config.toml /
|
||||
// etc.) into the per-container session dir. Replaces the older pattern of
|
||||
// seedSessionDir copies the agent's auth files and selected configuration
|
||||
// bundles into the per-container session dir. Replaces the older pattern of
|
||||
// bind-mounting the host's whole ~/.<agent>, which leaked absolute host
|
||||
// paths into agent state DBs and broke resume on codex.
|
||||
func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreateRequest) {
|
||||
@@ -244,7 +244,17 @@ func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreate
|
||||
return
|
||||
}
|
||||
instanceRoot := InstanceSessionRoot(r.kandevHomeDir, req.InstanceID)
|
||||
if err := SeedAgentSessionDir(ctx, req.AgentConfig, instanceRoot, r.logger); err != nil {
|
||||
selectedBundles := selectedPortableConfigBundleIDs(req.Metadata)
|
||||
if err := seedAgentSessionDir(
|
||||
ctx,
|
||||
req.AgentConfig,
|
||||
instanceRoot,
|
||||
r.logger,
|
||||
selectedBundles,
|
||||
func(warnings []PortableConfigWarning) {
|
||||
reportPortableConfigWarnings(req.OnProgress, warnings)
|
||||
},
|
||||
); err != nil {
|
||||
r.logger.Warn("failed to seed agent session dir (continuing)",
|
||||
zap.String("instance_id", req.InstanceID),
|
||||
zap.String("agent_id", req.AgentConfig.ID()),
|
||||
@@ -663,10 +673,11 @@ func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorIns
|
||||
|
||||
// On destructive stop reasons (task/session deleted/archived), clean up
|
||||
// the kandev-managed per-container session dir so we don't leak GBs of
|
||||
// agent state on disk. Plain stops preserve the dir so resume re-attaches
|
||||
// to the same agent state, mirroring the Sprites preserve-on-stop rule.
|
||||
// agent state on disk. Plain stops and stale execution cleanup preserve the
|
||||
// dir because a resume may re-attach to the same container and its bind
|
||||
// mounts.
|
||||
teardownContainer := shouldTeardownDockerContainer(instance.StopReason)
|
||||
if teardownContainer && r.kandevHomeDir != "" && instance.InstanceID != "" {
|
||||
if shouldRunExecutorCleanup(instance.StopReason) && r.kandevHomeDir != "" && instance.InstanceID != "" {
|
||||
CleanupAgentSessionDir(InstanceSessionRoot(r.kandevHomeDir, instance.InstanceID), r.logger)
|
||||
}
|
||||
|
||||
@@ -726,10 +737,10 @@ func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorIns
|
||||
}
|
||||
|
||||
// shouldTeardownDockerContainer extends terminal cleanup with stale execution
|
||||
// cleanup for Docker only. A stale Docker execution owns a local container and
|
||||
// per-instance session dir that become untracked before retry/resume launches a
|
||||
// replacement. Sprites intentionally keep stale sandboxes; see
|
||||
// shouldRunExecutorCleanup for that shared runtime policy.
|
||||
// cleanup for Docker only. Stale cleanup must stop the old container before a
|
||||
// retry/resume launch, but its per-instance session dir must remain available
|
||||
// because the stopped container still references its bind mounts. Destructive
|
||||
// task/session lifecycle reasons own removal; see shouldRunExecutorCleanup.
|
||||
func shouldTeardownDockerContainer(reason string) bool {
|
||||
if shouldRunExecutorCleanup(reason) {
|
||||
return true
|
||||
|
||||
@@ -30,21 +30,17 @@ func (r *SpritesExecutor) uploadCredentials(
|
||||
// Run auth setup scripts for env-type methods (e.g., Claude Code credential files)
|
||||
r.runAuthSetupScripts(ctx, sprite, req, catalog, onOutput)
|
||||
|
||||
credsJSON, _ := req.Metadata["remote_credentials"].(string)
|
||||
if credsJSON == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var selectedMethodIDs []string
|
||||
if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil {
|
||||
return fmt.Errorf("failed to parse remote_credentials: %w", err)
|
||||
if credsJSON, _ := req.Metadata["remote_credentials"].(string); credsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil {
|
||||
return fmt.Errorf("failed to parse remote_credentials: %w", err)
|
||||
}
|
||||
// Ignore the retired host-global gh token method in stale profiles. GitHub
|
||||
// automation credentials are supplied by the workspace credential broker.
|
||||
selectedMethodIDs = r.resolveGHToken(selectedMethodIDs)
|
||||
}
|
||||
|
||||
// Ignore the retired host-global gh token method in stale profiles. GitHub
|
||||
// automation credentials are supplied by the workspace credential broker.
|
||||
selectedMethodIDs = r.resolveGHToken(selectedMethodIDs)
|
||||
|
||||
if len(selectedMethodIDs) == 0 {
|
||||
selectedBundles := selectedPortableConfigBundleIDs(req.Metadata)
|
||||
if len(selectedMethodIDs) == 0 && len(selectedBundles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -60,10 +56,6 @@ func (r *SpritesExecutor) uploadCredentials(
|
||||
}
|
||||
fileMethods = append(fileMethods, method)
|
||||
}
|
||||
if len(fileMethods) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stepCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -72,7 +64,18 @@ func (r *SpritesExecutor) uploadCredentials(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger)
|
||||
var uploadErr error
|
||||
if len(fileMethods) > 0 {
|
||||
uploadErr = UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger)
|
||||
if uploadErr != nil {
|
||||
r.logger.Warn("sprites executor: credential files failed; continuing with configuration bundles", zap.Error(uploadErr))
|
||||
}
|
||||
}
|
||||
if len(selectedBundles) > 0 && req.AgentConfig != nil {
|
||||
warnings := UploadPortableConfigBundles(stepCtx, uploader, req.AgentConfig, selectedBundles, targetHomeDir, r.logger)
|
||||
reportPortableConfigWarnings(req.OnProgress, warnings)
|
||||
}
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
// runAuthSetupScripts executes setup scripts for env-type auth methods whose env var
|
||||
|
||||
@@ -36,20 +36,17 @@ func (r *SSHExecutor) uploadCredentials(
|
||||
r.resolveAuthSecrets(ctx, req, catalog)
|
||||
r.runAuthSetupScripts(ctx, client, req, catalog, platform)
|
||||
|
||||
credsJSON := getMetadataString(req.Metadata, "remote_credentials")
|
||||
if credsJSON == "" {
|
||||
return nil
|
||||
var fileMethods []remoteauth.Method
|
||||
if credsJSON := getMetadataString(req.Metadata, "remote_credentials"); credsJSON != "" {
|
||||
var selectedMethodIDs []string
|
||||
if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil {
|
||||
return fmt.Errorf("failed to parse remote_credentials: %w", err)
|
||||
}
|
||||
selectedMethodIDs = r.resolveGHToken(selectedMethodIDs)
|
||||
fileMethods = selectFileMethods(catalog, selectedMethodIDs, r.logger)
|
||||
}
|
||||
|
||||
var selectedMethodIDs []string
|
||||
if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil {
|
||||
return fmt.Errorf("failed to parse remote_credentials: %w", err)
|
||||
}
|
||||
|
||||
selectedMethodIDs = r.resolveGHToken(selectedMethodIDs)
|
||||
|
||||
fileMethods := selectFileMethods(catalog, selectedMethodIDs, r.logger)
|
||||
if len(fileMethods) == 0 {
|
||||
selectedBundles := selectedPortableConfigBundleIDs(req.Metadata)
|
||||
if len(fileMethods) == 0 && len(selectedBundles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -58,7 +55,18 @@ func (r *SSHExecutor) uploadCredentials(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return UploadCredentialFiles(ctx, uploader, fileMethods, homeDir, r.logger)
|
||||
var uploadErr error
|
||||
if len(fileMethods) > 0 {
|
||||
uploadErr = UploadCredentialFiles(ctx, uploader, fileMethods, homeDir, r.logger)
|
||||
if uploadErr != nil {
|
||||
r.logger.Warn("ssh executor: credential files failed; continuing with configuration bundles", zap.Error(uploadErr))
|
||||
}
|
||||
}
|
||||
if len(selectedBundles) > 0 && req.AgentConfig != nil {
|
||||
warnings := UploadPortableConfigBundles(ctx, uploader, req.AgentConfig, selectedBundles, homeDir, r.logger)
|
||||
reportPortableConfigWarnings(req.OnProgress, warnings)
|
||||
}
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
// runAuthSetupScripts executes setup scripts for env-type auth methods whose
|
||||
|
||||
@@ -15,11 +15,17 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
||||
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
freshSessionModelStateWait = 2 * time.Second
|
||||
freshSessionModelStatePoll = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// WasSessionInitialized reports whether the execution completed ACP session setup.
|
||||
// Returns false if the execution is not found or init hasn't completed.
|
||||
func (m *Manager) WasSessionInitialized(executionID string) bool {
|
||||
@@ -441,12 +447,8 @@ func (m *Manager) reapplySessionModeAfterReset(ctx context.Context, execution *A
|
||||
zap.String("mode", mode))
|
||||
}
|
||||
|
||||
// reapplySessionModelAfterReset re-applies the effective model to a freshly
|
||||
// initialized ACP session under the no-silent-model-fallback policy: a model
|
||||
// that is gone in the fresh session's advertised list fails the reset
|
||||
// explicitly (returning an error) unless the profile opted into a fallback
|
||||
// model or the legacy auto-fallback toggle. Previously the re-apply was
|
||||
// best-effort, silently leaving the fresh session on the provider default.
|
||||
// reapplySessionModelAfterReset applies the executor-authoritative model
|
||||
// decision to a freshly initialized ACP session.
|
||||
func (m *Manager) reapplySessionModelAfterReset(
|
||||
ctx context.Context,
|
||||
execution *AgentExecution,
|
||||
@@ -457,7 +459,7 @@ func (m *Manager) reapplySessionModelAfterReset(
|
||||
}
|
||||
policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID)
|
||||
policy.Model = modelID
|
||||
appliedModel, usingFallback, err := applyStartModelPolicy(
|
||||
decision, err := applyStartModelPolicy(
|
||||
ctx, m.logger, execution.agentctl, execution.GetModelState(), policy,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -467,20 +469,79 @@ func (m *Manager) reapplySessionModelAfterReset(
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if appliedModel != "" {
|
||||
if decision.Warning && m.sessionManager != nil {
|
||||
m.sessionManager.publishModelSelectionWarningEvent(execution, newSessionID, decision)
|
||||
}
|
||||
if decision.EffectiveModel != "" &&
|
||||
(decision.Outcome == ModelSelectionOutcomeApplied || decision.Outcome == ModelSelectionOutcomeExplicitFallback) {
|
||||
m.logger.Info("re-applied session model after context reset",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("session_id", execution.SessionID),
|
||||
zap.String("new_acp_session_id", newSessionID),
|
||||
zap.String("model", appliedModel),
|
||||
zap.Bool("using_fallback", usingFallback))
|
||||
if usingFallback && m.sessionManager != nil {
|
||||
m.sessionManager.publishModelFallbackEvent(execution, newSessionID, appliedModel)
|
||||
}
|
||||
zap.String("model", decision.EffectiveModel),
|
||||
zap.Bool("using_fallback", decision.Outcome == ModelSelectionOutcomeExplicitFallback))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheFreshSessionModelState(execution *AgentExecution) bool {
|
||||
if execution == nil || execution.agentctl == nil {
|
||||
return false
|
||||
}
|
||||
state := execution.agentctl.GetLastSessionModelState()
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
execution.SetModelState(&CachedModelState{
|
||||
CurrentModelID: state.CurrentModelID,
|
||||
Models: state.Models,
|
||||
ConfigOptions: state.ConfigOptions,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// waitForFreshSessionModelState gives the agent stream a chance to deliver the
|
||||
// new session's model catalog before the reset/restart policy runs. ACP
|
||||
// session/new returns before its session_models notification is dispatched to
|
||||
// the lifecycle manager. This is the fallback for transports that do not
|
||||
// include a synchronous snapshot in their session response. Reading the old
|
||||
// nil/empty cache at that boundary would incorrectly treat an advertised model
|
||||
// as unavailable and leave the agent on its provider default. A missing
|
||||
// notification still follows the executor-authoritative default path after the
|
||||
// bounded wait.
|
||||
func waitForFreshSessionModelState(ctx context.Context, log *logger.Logger, execution *AgentExecution) bool {
|
||||
if execution == nil {
|
||||
return false
|
||||
}
|
||||
if freshSessionModelCatalogReady(execution.GetModelState()) {
|
||||
return true
|
||||
}
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, freshSessionModelStateWait)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(freshSessionModelStatePoll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
if log != nil {
|
||||
log.Debug("fresh session model catalog was not reported before policy evaluation",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.Error(waitCtx.Err()))
|
||||
}
|
||||
return false
|
||||
case <-ticker.C:
|
||||
if freshSessionModelCatalogReady(execution.GetModelState()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func freshSessionModelCatalogReady(state *CachedModelState) bool {
|
||||
return state != nil && len(state.Models) > 0
|
||||
}
|
||||
|
||||
func (m *Manager) effectiveSessionModelForReset(ctx context.Context, execution *AgentExecution) string {
|
||||
modelFallback := ""
|
||||
if previous := execution.GetModelState(); previous != nil {
|
||||
@@ -518,6 +579,13 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err
|
||||
prevMode := execution.GetModeState()
|
||||
effectiveModel := m.effectiveSessionModelForReset(ctx, execution)
|
||||
|
||||
// Clear the old catalog before creating the fresh session. The new
|
||||
// session_models event is delivered asynchronously and must be the only
|
||||
// catalog consulted by the reset decision.
|
||||
_ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) {
|
||||
exec.SetModelState(nil)
|
||||
})
|
||||
|
||||
// Resolve agent config and MCP servers for session reset
|
||||
agentConfig, err := m.getAgentConfigForExecution(execution)
|
||||
if err != nil {
|
||||
@@ -550,13 +618,6 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err
|
||||
|
||||
m.resetStreamingStateWithHistory(exec)
|
||||
|
||||
// The cached model state describes the OLD ACP session. The fresh
|
||||
// session has not advertised its models yet, so evaluating the start
|
||||
// model against the stale list could reject a model the new session
|
||||
// accepts (or vice versa). Clear it: reapplySessionModelAfterReset
|
||||
// then relies on the fresh session's own SetModel result.
|
||||
exec.SetModelState(nil)
|
||||
|
||||
// Drain any stale prompt completion signal
|
||||
select {
|
||||
case <-exec.promptDoneCh:
|
||||
@@ -568,6 +629,9 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err
|
||||
// onto the fresh ACP session. A strict-mode model that is gone in the new
|
||||
// session fails the reset explicitly instead of silently dropping to the
|
||||
// provider default.
|
||||
if !cacheFreshSessionModelState(execution) && effectiveModel != "" {
|
||||
waitForFreshSessionModelState(ctx, m.logger, execution)
|
||||
}
|
||||
if err := m.reapplySessionModelAfterReset(ctx, execution, newSessionID, effectiveModel); err != nil {
|
||||
// The reset already committed the execution as Ready; a model
|
||||
// re-application failure must not leave it there, or later prompts
|
||||
@@ -791,6 +855,14 @@ func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) e
|
||||
return fmt.Errorf("failed to initialize ACP session after restart: %w", err)
|
||||
}
|
||||
|
||||
if !cacheFreshSessionModelState(execution) && preparation.previousModel != "" {
|
||||
waitForFreshSessionModelState(ctx, m.logger, execution)
|
||||
if err := m.reapplySessionModelAfterReset(ctx, execution, execution.ACPSessionID, preparation.previousModel); err != nil {
|
||||
m.updateExecutionError(executionID, "failed to restore session model after restart: "+err.Error())
|
||||
return fmt.Errorf("failed to restore session model after restart: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the user's session permission mode onto the fresh ACP session.
|
||||
m.reapplySessionModeAfterReset(ctx, execution, execution.ACPSessionID, preparation.previousMode)
|
||||
|
||||
@@ -804,9 +876,10 @@ func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) e
|
||||
}
|
||||
|
||||
type agentRestartPreparation struct {
|
||||
agentConfig agents.Agent
|
||||
commands agentCommands
|
||||
previousMode *CachedModeState
|
||||
agentConfig agents.Agent
|
||||
commands agentCommands
|
||||
previousModel string
|
||||
previousMode *CachedModeState
|
||||
}
|
||||
|
||||
// prepareAgentRestart builds and validates the replacement before touching the current process.
|
||||
@@ -825,9 +898,10 @@ func (m *Manager) prepareAgentRestart(ctx context.Context, execution *AgentExecu
|
||||
return agentRestartPreparation{}, fmt.Errorf("failed to rebuild agent command for restart: %w", err)
|
||||
}
|
||||
return agentRestartPreparation{
|
||||
agentConfig: agentConfig,
|
||||
commands: commands,
|
||||
previousMode: execution.GetModeState(),
|
||||
agentConfig: agentConfig,
|
||||
commands: commands,
|
||||
previousModel: m.effectiveSessionModelForReset(ctx, execution),
|
||||
previousMode: execution.GetModeState(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -839,6 +913,7 @@ func (m *Manager) resetAgentRestartState(executionID string, commands agentComma
|
||||
exec.needsResumeContext = false
|
||||
exec.resumeContextInjected = false
|
||||
exec.setSessionInitialized(false)
|
||||
exec.SetModelState(nil)
|
||||
exec.AgentCommand = commands.initial
|
||||
exec.ContinueCommand = commands.continue_
|
||||
exec.AgentArgs = commands.args
|
||||
|
||||
@@ -46,6 +46,7 @@ type restartMockAgentctlServer struct {
|
||||
|
||||
failStop bool
|
||||
failSessionNew bool
|
||||
modelState *streams.SessionModelState
|
||||
}
|
||||
|
||||
func TestStopAgentWithReason_MissingExecutionIsClassified(t *testing.T) {
|
||||
@@ -56,6 +57,21 @@ func TestStopAgentWithReason_MissingExecutionIsClassified(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrExecutionNotFound)
|
||||
}
|
||||
|
||||
func TestWaitForFreshSessionModelStateWaitsForAdvertisedCatalog(t *testing.T) {
|
||||
execution := &AgentExecution{ID: "exec-model-catalog"}
|
||||
execution.SetModelState(&CachedModelState{CurrentModelID: "mock-fast"})
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
execution.SetModelState(&CachedModelState{
|
||||
CurrentModelID: "mock-fast",
|
||||
Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}},
|
||||
})
|
||||
}()
|
||||
|
||||
require.True(t, waitForFreshSessionModelState(context.Background(), newTestLogger(), execution))
|
||||
}
|
||||
|
||||
func newRestartMockAgentctlServer(t *testing.T, failStop, failSessionNew bool) *restartMockAgentctlServer {
|
||||
t.Helper()
|
||||
|
||||
@@ -135,10 +151,14 @@ func newRestartMockAgentctlServer(t *testing.T, failStop, failSessionNew bool) *
|
||||
})
|
||||
}
|
||||
case "agent.session.reset":
|
||||
resp, _ = ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"success": true,
|
||||
"session_id": "reset-session-456",
|
||||
})
|
||||
}
|
||||
if m.modelState != nil {
|
||||
payload["model_state"] = m.modelState
|
||||
}
|
||||
resp, _ = ws.NewResponse(msg.ID, msg.Action, payload)
|
||||
case "agent.session.set_mode":
|
||||
resp, _ = ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -758,10 +778,52 @@ func TestManager_ResetAgentContext_ReappliesSessionModel(t *testing.T) {
|
||||
resetIndex := slices.Index(actions, "agent.session.reset")
|
||||
modelIndex := slices.Index(actions, "agent.session.set_model")
|
||||
require.GreaterOrEqual(t, resetIndex, 0)
|
||||
require.Greater(t, modelIndex, resetIndex,
|
||||
"the model must be reapplied after the fresh ACP session is created")
|
||||
require.Equal(t, -1, modelIndex,
|
||||
"an empty fresh-session model catalog must not receive a model-selection request")
|
||||
require.Empty(t, mock.getSetModelIDs(),
|
||||
"reset must continue on the fresh-session provider default when no model is advertised")
|
||||
}
|
||||
|
||||
func TestManager_ResetAgentContext_UsesSynchronousSessionModelCatalog(t *testing.T) {
|
||||
mgr := newTestManager(t)
|
||||
mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{
|
||||
infos: map[string]*WorkspaceInfo{
|
||||
"session-1": {SessionID: "session-1", RuntimeModel: "mock-smart"},
|
||||
},
|
||||
}
|
||||
mock := newRestartMockAgentctlServer(t, false, false)
|
||||
mock.modelState = &streams.SessionModelState{
|
||||
CurrentModelID: "mock-fast",
|
||||
Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}},
|
||||
}
|
||||
|
||||
client := createTestClient(t, mock.server.URL)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, client.StreamUpdates(ctx, func(agentctl.AgentEvent) {}, nil, nil))
|
||||
|
||||
exec := &AgentExecution{
|
||||
ID: "exec-model-reset-response",
|
||||
TaskID: "task-1",
|
||||
SessionID: "session-1",
|
||||
AgentProfileID: "profile-1",
|
||||
ACPSessionID: "old-session",
|
||||
AgentCommand: "auggie --model test",
|
||||
Status: v1.AgentStatusRunning,
|
||||
WorkspacePath: "/workspace",
|
||||
sessionInitialized: true,
|
||||
agentctl: client,
|
||||
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
||||
}
|
||||
exec.SetModelState(&CachedModelState{CurrentModelID: "mock-fast"})
|
||||
require.NoError(t, mgr.executionStore.Add(exec))
|
||||
|
||||
require.NoError(t, mgr.ResetAgentContext(ctx, exec.ID))
|
||||
|
||||
require.Equal(t, []string{"mock-smart"}, mock.getSetModelIDs(),
|
||||
"reset must restore the persisted effective model, not the fresh-session default")
|
||||
"reset must use the synchronous session catalog before the stream event is dispatched")
|
||||
}
|
||||
|
||||
// TestManager_RestartAgentProcess_PrefersPersistedModeOverStaleCache is the
|
||||
|
||||
@@ -140,8 +140,11 @@ func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentE
|
||||
}
|
||||
previousMode := execution.GetModeState()
|
||||
previousModel := execution.GetModelState()
|
||||
previousModelID := m.effectiveSessionModelForReset(ctx, execution)
|
||||
execution.SetModelState(nil)
|
||||
newSessionID, err := execution.agentctl.NewSession(ctx, execution.WorkspacePath, mcpServers)
|
||||
if err != nil {
|
||||
execution.SetModelState(previousModel)
|
||||
return fmt.Errorf("create ACP session in rebound workspace: %w", err)
|
||||
}
|
||||
execution.ACPSessionID = newSessionID
|
||||
@@ -149,7 +152,12 @@ func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentE
|
||||
execution.resumeContextInjected = false
|
||||
execution.needsResumeContext = m.historyManager != nil &&
|
||||
m.historyManager.HasHistory(execution.SessionID)
|
||||
m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousMode)
|
||||
if !cacheFreshSessionModelState(execution) && previousModelID != "" {
|
||||
waitForFreshSessionModelState(ctx, m.logger, execution)
|
||||
}
|
||||
if err := m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousModelID, previousMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.eventPublisher != nil {
|
||||
m.eventPublisher.PublishACPSessionCreated(execution, newSessionID)
|
||||
}
|
||||
@@ -161,14 +169,22 @@ func (m *Manager) reapplyReboundSessionConfig(
|
||||
execution *AgentExecution,
|
||||
sessionID string,
|
||||
model *CachedModelState,
|
||||
modelID string,
|
||||
mode *CachedModeState,
|
||||
) {
|
||||
if model != nil && model.CurrentModelID != "" {
|
||||
if err := execution.agentctl.SetModel(ctx, model.CurrentModelID); err != nil {
|
||||
) error {
|
||||
if modelID != "" {
|
||||
policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID)
|
||||
policy.Model = modelID
|
||||
decision, err := applyStartModelPolicy(ctx, m.logger, execution.agentctl, execution.GetModelState(), policy)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to re-apply model after workspace rebind",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("model", model.CurrentModelID),
|
||||
zap.String("model", modelID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if decision.Warning && m.sessionManager != nil {
|
||||
m.sessionManager.publishModelSelectionWarningEvent(execution, sessionID, decision)
|
||||
}
|
||||
}
|
||||
if model != nil {
|
||||
@@ -187,6 +203,7 @@ func (m *Manager) reapplyReboundSessionConfig(
|
||||
}
|
||||
}
|
||||
m.reapplySessionModeAfterReset(ctx, execution, sessionID, mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForReboundAgentReady(ctx context.Context, execution *AgentExecution) error {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/remoteconfig"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// selectedPortableConfigBundleIDs decodes the executor-profile value without
|
||||
// accepting file paths or file data from task metadata.
|
||||
func selectedPortableConfigBundleIDs(metadata map[string]interface{}) []string {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := metadata[MetadataKeyAgentConfigBundles]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if value, ok := raw.(string); ok {
|
||||
var ids []string
|
||||
if json.Unmarshal([]byte(value), &ids) == nil {
|
||||
return ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if values, ok := raw.([]string); ok {
|
||||
return append([]string(nil), values...)
|
||||
}
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if id, ok := value.(string); ok && id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const (
|
||||
portableConfigMaxFileBytes int64 = 1 << 20
|
||||
portableConfigMaxLaunchBytes int64 = 4 << 20
|
||||
portableConfigReasonUnsafeSourcePath = "unsafe_source_path"
|
||||
)
|
||||
|
||||
const portableConfigFileMode os.FileMode = 0o600
|
||||
|
||||
// PortableConfigWarning describes an optional configuration copy that was
|
||||
// skipped. It deliberately contains paths relative to the host home only.
|
||||
type PortableConfigWarning struct {
|
||||
BundleID string `json:"bundle_id"`
|
||||
SourcePath string `json:"source_path,omitempty"`
|
||||
TargetPath string `json:"target_path,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func reportPortableConfigWarnings(onProgress PrepareProgressCallback, warnings []PortableConfigWarning) {
|
||||
if onProgress == nil || len(warnings) == 0 {
|
||||
return
|
||||
}
|
||||
step := beginStep("Copy agent configuration")
|
||||
details := make([]string, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
details = append(details, fmt.Sprintf("%s: %s", warning.SourcePath, warning.Reason))
|
||||
}
|
||||
step.Warning = details[0]
|
||||
if len(details) > 1 {
|
||||
step.WarningDetail = strings.Join(details, "\n")
|
||||
}
|
||||
completeStepSuccess(&step)
|
||||
onProgress(step, 0, 0)
|
||||
}
|
||||
|
||||
// UploadPortableConfigBundles copies selected, backend-declared configuration
|
||||
// files from the host home into an executor home. Every copy is optional:
|
||||
// invalid, missing, oversized, or failed files produce warnings and the next
|
||||
// file continues. The raw data never enters metadata or the browser.
|
||||
func UploadPortableConfigBundles(
|
||||
ctx context.Context,
|
||||
uploader FileUploader,
|
||||
ag agents.Agent,
|
||||
selectedIDs []string,
|
||||
targetHomeDir string,
|
||||
log *logger.Logger,
|
||||
) []PortableConfigWarning {
|
||||
if ag == nil || uploader == nil || len(selectedIDs) == 0 || targetHomeDir == "" {
|
||||
return nil
|
||||
}
|
||||
hostHome, err := os.UserHomeDir()
|
||||
if err != nil || hostHome == "" {
|
||||
return []PortableConfigWarning{{Reason: "host_home_unavailable"}}
|
||||
}
|
||||
catalog := remoteconfig.BuildCatalogForHost([]agents.Agent{ag}, runtime.GOOS, hostHome)
|
||||
warnings := make([]PortableConfigWarning, 0)
|
||||
var copiedBytes int64
|
||||
seen := make(map[string]struct{}, len(selectedIDs))
|
||||
for _, bundleID := range selectedIDs {
|
||||
if _, exists := seen[bundleID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[bundleID] = struct{}{}
|
||||
bundle, ok := catalog.FindBundle(bundleID)
|
||||
if !ok {
|
||||
warnings = append(warnings, PortableConfigWarning{BundleID: bundleID, Reason: "unknown_bundle"})
|
||||
continue
|
||||
}
|
||||
for _, file := range bundle.Files {
|
||||
warning, copied, size := uploadPortableConfigFile(
|
||||
ctx, uploader, hostHome, targetHomeDir, bundle.ID, file, copiedBytes,
|
||||
)
|
||||
if warning.Reason != "" {
|
||||
warnings = append(warnings, warning)
|
||||
if log != nil {
|
||||
log.Warn("portable agent configuration copy skipped",
|
||||
zap.String("bundle_id", warning.BundleID),
|
||||
zap.String("source_path", warning.SourcePath),
|
||||
zap.String("reason", warning.Reason))
|
||||
}
|
||||
}
|
||||
if copied {
|
||||
copiedBytes += size
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func uploadPortableConfigFile(
|
||||
ctx context.Context,
|
||||
uploader FileUploader,
|
||||
hostHome, targetHome, bundleID string,
|
||||
file remoteconfig.File,
|
||||
copiedBytes int64,
|
||||
) (PortableConfigWarning, bool, int64) {
|
||||
warning := PortableConfigWarning{
|
||||
BundleID: bundleID,
|
||||
SourcePath: file.SourcePath,
|
||||
TargetPath: file.TargetPath,
|
||||
}
|
||||
if !isSafePortableRelativePath(file.SourcePath) {
|
||||
warning.Reason = portableConfigReasonUnsafeSourcePath
|
||||
return warning, false, 0
|
||||
}
|
||||
if !isSafePortableRelativePath(file.TargetPath) {
|
||||
warning.Reason = "unsafe_target_path"
|
||||
return warning, false, 0
|
||||
}
|
||||
sourcePath, err := containedPath(hostHome, filepath.Join(hostHome, filepath.FromSlash(file.SourcePath)))
|
||||
if err != nil {
|
||||
warning.Reason = portableConfigReasonUnsafeSourcePath
|
||||
return warning, false, 0
|
||||
}
|
||||
targetPath, err := containedPath(targetHome, filepath.Join(targetHome, filepath.FromSlash(file.TargetPath)))
|
||||
if err != nil {
|
||||
warning.Reason = "unsafe_target_path"
|
||||
return warning, false, 0
|
||||
}
|
||||
info, reason := portableConfigSourceInfo(hostHome, sourcePath)
|
||||
if reason != "" {
|
||||
warning.Reason = reason
|
||||
return warning, false, 0
|
||||
}
|
||||
if info.Size() > portableConfigMaxFileBytes {
|
||||
warning.Reason = "file_too_large"
|
||||
return warning, false, 0
|
||||
}
|
||||
if copiedBytes > portableConfigMaxLaunchBytes-info.Size() {
|
||||
warning.Reason = "launch_size_limit"
|
||||
return warning, false, 0
|
||||
}
|
||||
data, err := readPortableConfigFile(sourcePath, info)
|
||||
if err != nil {
|
||||
warning.Reason = "source_read_failed"
|
||||
return warning, false, 0
|
||||
}
|
||||
if err := uploader.WriteFile(ctx, targetPath, data, portableConfigFileMode); err != nil {
|
||||
warning.Reason = "target_write_failed"
|
||||
return warning, false, 0
|
||||
}
|
||||
return PortableConfigWarning{}, true, int64(len(data))
|
||||
}
|
||||
|
||||
func portableConfigSourceInfo(root, path string) (os.FileInfo, string) {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return nil, portableConfigReasonUnsafeSourcePath
|
||||
}
|
||||
|
||||
current := filepath.Clean(root)
|
||||
var info os.FileInfo
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, part)
|
||||
info, err = os.Lstat(current)
|
||||
if err != nil {
|
||||
return nil, "source_missing"
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, "source_symlink"
|
||||
}
|
||||
}
|
||||
if info == nil || !info.Mode().IsRegular() {
|
||||
return nil, "source_not_regular"
|
||||
}
|
||||
return info, ""
|
||||
}
|
||||
|
||||
func readPortableConfigFile(path string, expectedInfo os.FileInfo) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
actualInfo, err := file.Stat()
|
||||
if err != nil || !actualInfo.Mode().IsRegular() || !os.SameFile(expectedInfo, actualInfo) {
|
||||
return nil, fmt.Errorf("file changed while opening")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, portableConfigMaxFileBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > portableConfigMaxFileBytes || int64(len(data)) != expectedInfo.Size() {
|
||||
return nil, fmt.Errorf("file changed while reading")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func isSafePortableRelativePath(path string) bool {
|
||||
if path == "" || filepath.IsAbs(filepath.FromSlash(path)) {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(path))
|
||||
return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
)
|
||||
|
||||
type recordingPortableConfigUploader struct {
|
||||
writes []portableConfigWrite
|
||||
}
|
||||
|
||||
type portableConfigWrite struct {
|
||||
path string
|
||||
data []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func (u *recordingPortableConfigUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error {
|
||||
u.writes = append(u.writes, portableConfigWrite{path: path, data: append([]byte(nil), data...), mode: mode})
|
||||
return nil
|
||||
}
|
||||
|
||||
type portableConfigTestAgent struct {
|
||||
agents.Agent
|
||||
config *agents.PortableConfig
|
||||
}
|
||||
|
||||
func (a portableConfigTestAgent) PortableConfig() *agents.PortableConfig { return a.config }
|
||||
|
||||
func TestUploadPortableConfigBundlesCopiesSelectedFilesWithPrivateMode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFile(t, home, ".agent/settings.json", []byte("settings"))
|
||||
writeFile(t, home, ".agent/optional.json", []byte("optional"))
|
||||
|
||||
ag := portableConfigTestAgent{
|
||||
Agent: agents.NewMockAgent(),
|
||||
config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{
|
||||
ID: "test.config", Label: "Test config", Files: []agents.PortableConfigFile{
|
||||
{SourcePaths: map[string]string{"linux": ".agent/settings.json"}, TargetPath: ".agent/settings.json"},
|
||||
{SourcePaths: map[string]string{"linux": ".agent/optional.json"}, TargetPath: ".agent/optional.json"},
|
||||
},
|
||||
}}},
|
||||
}
|
||||
uploader := &recordingPortableConfigUploader{}
|
||||
|
||||
warnings := UploadPortableConfigBundles(
|
||||
context.Background(), uploader, ag, []string{"test.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t),
|
||||
)
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %+v, want none", warnings)
|
||||
}
|
||||
if len(uploader.writes) != 2 {
|
||||
t.Fatalf("writes = %+v, want two selected files", uploader.writes)
|
||||
}
|
||||
for _, write := range uploader.writes {
|
||||
if write.mode != 0o600 {
|
||||
t.Errorf("mode for %s = %o, want 600", write.path, write.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPortableConfigBundlesRejectsUnsafeAndOversizedFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFile(t, home, ".agent/link.json", []byte("link"))
|
||||
if err := os.Symlink(filepath.Join(home, ".agent", "link.json"), filepath.Join(home, ".agent", "symlink.json")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
writeFile(t, home, ".agent/large.json", make([]byte, portableConfigMaxFileBytes+1))
|
||||
|
||||
ag := portableConfigTestAgent{
|
||||
Agent: agents.NewMockAgent(),
|
||||
config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{
|
||||
ID: "unsafe.config", Label: "Unsafe config", Files: []agents.PortableConfigFile{
|
||||
{SourcePaths: map[string]string{"linux": ".agent/symlink.json"}, TargetPath: ".agent/symlink.json"},
|
||||
{SourcePaths: map[string]string{"linux": ".agent/large.json"}, TargetPath: ".agent/large.json"},
|
||||
{SourcePaths: map[string]string{"linux": ".agent/link.json"}, TargetPath: "../escape.json"},
|
||||
},
|
||||
}}},
|
||||
}
|
||||
uploader := &recordingPortableConfigUploader{}
|
||||
warnings := UploadPortableConfigBundles(
|
||||
context.Background(), uploader, ag, []string{"unsafe.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t),
|
||||
)
|
||||
if len(uploader.writes) != 0 {
|
||||
t.Fatalf("unsafe files were copied: %+v", uploader.writes)
|
||||
}
|
||||
if len(warnings) != 3 {
|
||||
t.Fatalf("warnings = %+v, want one for each rejected file", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPortableConfigBundlesWarnsAndContinuesForMissingFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFile(t, home, ".agent/present.json", []byte("present"))
|
||||
ag := portableConfigTestAgent{
|
||||
Agent: agents.NewMockAgent(),
|
||||
config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{
|
||||
ID: "partial.config", Label: "Partial config", Files: []agents.PortableConfigFile{
|
||||
{SourcePaths: map[string]string{"linux": ".agent/present.json"}, TargetPath: ".agent/present.json"},
|
||||
{SourcePaths: map[string]string{"linux": ".agent/missing.json"}, TargetPath: ".agent/missing.json"},
|
||||
},
|
||||
}}},
|
||||
}
|
||||
uploader := &recordingPortableConfigUploader{}
|
||||
warnings := UploadPortableConfigBundles(
|
||||
context.Background(), uploader, ag, []string{"partial.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t),
|
||||
)
|
||||
if len(uploader.writes) != 1 {
|
||||
t.Fatalf("writes = %+v, want present file copied", uploader.writes)
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings = %+v, want missing-file warning", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPortableConfigFileRejectsPathReplacement(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "settings.json")
|
||||
writeFile(t, root, "settings.json", []byte("original"))
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("lstat source: %v", err)
|
||||
}
|
||||
replacement := filepath.Join(root, "replacement.json")
|
||||
writeFile(t, root, "replacement.json", []byte("replacement"))
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove source: %v", err)
|
||||
}
|
||||
if err := os.Symlink(replacement, path); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readPortableConfigFile(path, info); err == nil {
|
||||
t.Fatal("expected replaced source path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPortableConfigBundlesRejectsSymlinkedDestinations(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFile(t, home, ".agent/settings.json", []byte("settings"))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
targetPath string
|
||||
prepare func(t *testing.T, root, outside string) string
|
||||
}{
|
||||
{
|
||||
name: "final file",
|
||||
targetPath: ".agent/settings.json",
|
||||
prepare: func(t *testing.T, root, outside string) string {
|
||||
t.Helper()
|
||||
target := filepath.Join(root, ".agent", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("mkdir target parent: %v", err)
|
||||
}
|
||||
outsideTarget := filepath.Join(outside, "settings.json")
|
||||
writeFile(t, outside, "settings.json", []byte("outside"))
|
||||
if err := os.Symlink(outsideTarget, target); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
return outsideTarget
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parent directory",
|
||||
targetPath: ".redirect/settings.json",
|
||||
prepare: func(t *testing.T, root, outside string) string {
|
||||
t.Helper()
|
||||
parent := filepath.Join(root, ".redirect")
|
||||
outsideTarget := filepath.Join(outside, "settings.json")
|
||||
writeFile(t, outside, "settings.json", []byte("outside"))
|
||||
if err := os.Symlink(outside, parent); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
return outsideTarget
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideTarget := tc.prepare(t, root, outside)
|
||||
ag := portableConfigTestAgent{
|
||||
Agent: agents.NewMockAgent(),
|
||||
config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{
|
||||
ID: "test.config", Label: "Test config", Files: []agents.PortableConfigFile{{
|
||||
SourcePaths: map[string]string{
|
||||
"linux": ".agent/settings.json",
|
||||
"darwin": ".agent/settings.json",
|
||||
"windows": ".agent/settings.json",
|
||||
},
|
||||
TargetPath: tc.targetPath,
|
||||
}},
|
||||
}}},
|
||||
}
|
||||
|
||||
warnings := UploadPortableConfigBundles(
|
||||
context.Background(), localFileUploader{root: root}, ag,
|
||||
[]string{"test.config"}, root, newSeederTestLogger(t),
|
||||
)
|
||||
if len(warnings) != 1 || warnings[0].Reason != "target_write_failed" {
|
||||
t.Fatalf("warnings = %+v, want target_write_failed", warnings)
|
||||
}
|
||||
contents, err := os.ReadFile(outsideTarget)
|
||||
if err != nil {
|
||||
t.Fatalf("read outside target: %v", err)
|
||||
}
|
||||
if string(contents) != "outside" {
|
||||
t.Fatalf("outside target = %q, want unchanged content", contents)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportPortableConfigWarningsPublishesOptionalCopyWarning(t *testing.T) {
|
||||
var got PrepareStep
|
||||
reportPortableConfigWarnings(func(step PrepareStep, _, _ int) {
|
||||
got = step
|
||||
}, []PortableConfigWarning{
|
||||
{BundleID: "test.config", SourcePath: ".agent/missing.json", Reason: "source_missing"},
|
||||
{BundleID: "test.config", SourcePath: ".agent/large.json", Reason: "file_too_large"},
|
||||
})
|
||||
|
||||
if got.Name != "Copy agent configuration" {
|
||||
t.Fatalf("step name = %q, want copy step", got.Name)
|
||||
}
|
||||
if got.Status != PrepareStepCompleted {
|
||||
t.Fatalf("step status = %q, want completed", got.Status)
|
||||
}
|
||||
if got.Warning == "" || got.WarningDetail == "" {
|
||||
t.Fatalf("step = %+v, want warning and warning detail", got)
|
||||
}
|
||||
if got.Output != "" {
|
||||
t.Fatalf("step output = %q, must not contain copied file data", got.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func writeAllFile(file *os.File, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
data = data[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build aix || android || darwin || dragonfly || freebsd || illumos || ios || linux || netbsd || openbsd || solaris
|
||||
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const safeFileDirectoryFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW
|
||||
|
||||
// writeFileWithinRoot walks the destination from an opened root directory.
|
||||
// O_NOFOLLOW is applied to every directory and the final file so an executor
|
||||
// cannot redirect a managed session write through a stale symlink.
|
||||
func writeFileWithinRoot(root, path string, data []byte, mode os.FileMode) error {
|
||||
_, parts, err := safeFilePathParts(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentFD, directoryFDs, err := openSafeFileDirectories(root, parts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
for index := len(directoryFDs) - 1; index >= 0; index-- {
|
||||
_ = unix.Close(directoryFDs[index])
|
||||
}
|
||||
}()
|
||||
return writeSafeFile(currentFD, path, parts[len(parts)-1], data, mode)
|
||||
}
|
||||
|
||||
func safeFilePathParts(root, path string) (string, []string, error) {
|
||||
cleanPath, err := containedPath(root, path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(root), cleanPath)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", nil, fmt.Errorf("path %q is not a file below session root %q", path, root)
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) == 0 || parts[len(parts)-1] == "" {
|
||||
return "", nil, fmt.Errorf("path %q has no file name", path)
|
||||
}
|
||||
return cleanPath, parts, nil
|
||||
}
|
||||
|
||||
func openSafeFileDirectories(root string, parts []string) (currentFD int, directoryFDs []int, err error) {
|
||||
rootFD, err := unix.Open(filepath.Clean(root), safeFileDirectoryFlags, 0)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("open session root %q: %w", root, err)
|
||||
}
|
||||
directoryFDs = []int{rootFD}
|
||||
currentFD = rootFD
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
for index := len(directoryFDs) - 1; index >= 0; index-- {
|
||||
_ = unix.Close(directoryFDs[index])
|
||||
}
|
||||
}()
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
nextFD, openErr := openSafeFileDirectory(currentFD, part)
|
||||
if openErr != nil {
|
||||
return 0, nil, openErr
|
||||
}
|
||||
directoryFDs = append(directoryFDs, nextFD)
|
||||
currentFD = nextFD
|
||||
}
|
||||
return currentFD, directoryFDs, nil
|
||||
}
|
||||
|
||||
func openSafeFileDirectory(parentFD int, name string) (int, error) {
|
||||
nextFD, err := unix.Openat(parentFD, name, safeFileDirectoryFlags, 0)
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
if mkdirErr := unix.Mkdirat(parentFD, name, 0o755); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) {
|
||||
return 0, fmt.Errorf("create session directory %q: %w", name, mkdirErr)
|
||||
}
|
||||
nextFD, err = unix.Openat(parentFD, name, safeFileDirectoryFlags, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open session directory %q: %w", name, err)
|
||||
}
|
||||
return nextFD, nil
|
||||
}
|
||||
|
||||
func writeSafeFile(parentFD int, path, name string, data []byte, mode os.FileMode) error {
|
||||
fileFD, err := unix.Openat(
|
||||
parentFD,
|
||||
name,
|
||||
unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_CLOEXEC|unix.O_NOFOLLOW,
|
||||
uint32(mode.Perm()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open session file %q: %w", path, err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fileFD), "")
|
||||
if file == nil {
|
||||
_ = unix.Close(fileFD)
|
||||
return fmt.Errorf("open session file %q: invalid file", path)
|
||||
}
|
||||
if err := unix.Fchmod(fileFD, uint32(mode.Perm())); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("chmod session file %q: %w", path, err)
|
||||
}
|
||||
if err := writeAllFile(file, data); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write session file %q: %w", path, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close session file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build windows
|
||||
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func writeFileWithinRoot(root, path string, data []byte, mode os.FileMode) error {
|
||||
cleanPath, err := containedPath(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectExistingSymlinkComponents(root, cleanPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(cleanPath), err)
|
||||
}
|
||||
file, err := os.OpenFile(cleanPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm())
|
||||
if err != nil {
|
||||
return fmt.Errorf("open session file %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if err := writeAllFile(file, data); err != nil {
|
||||
return fmt.Errorf("write session file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectExistingSymlinkComponents(root, path string) error {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := filepath.Clean(root)
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := os.Lstat(current)
|
||||
if os.IsNotExist(statErr) {
|
||||
continue
|
||||
}
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("session path component %q is a symlink", current)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -366,24 +368,24 @@ func (sm *SessionManager) InitializeAndPromptWithLayers(
|
||||
}
|
||||
|
||||
execution.ACPSessionID = result.SessionID
|
||||
if !cacheFreshSessionModelState(execution) && (profileModel != "" || runtimeModel != "") {
|
||||
waitForFreshSessionModelState(ctx, sm.logger, execution)
|
||||
}
|
||||
providerDefaultConfig := execution.GetModelState()
|
||||
|
||||
// No-silent-model-fallback: decide the effective model up front under the
|
||||
// profile's policy (a gone model fails the launch explicitly unless a
|
||||
// fallback model or the legacy auto-fallback toggle is configured), then
|
||||
// hand the decided model to the layers so it is applied exactly once.
|
||||
// Previously SetModel failures were best-effort — the session continued
|
||||
// on the provider default, which is the implicit switch this feature
|
||||
// eliminates. The gate is the effective model (which includes the
|
||||
// persisted runtime override), not the profile's start model: a profile
|
||||
// with no model and a session with a runtime model must still enforce
|
||||
// strict failure / the configured fallback.
|
||||
// Decide the effective model up front under the executor-authoritative
|
||||
// policy, then hand the decided model to the layers so it is applied once.
|
||||
// The gate is the effective model (which includes the persisted runtime
|
||||
// override), not only the profile's start model.
|
||||
effectiveModel := sm.applyStartModelPolicyToEffectiveModel(
|
||||
ctx, execution, result.SessionID, profileModel, runtimeModel, startModelPolicy,
|
||||
)
|
||||
if effectiveModel.err != nil {
|
||||
return effectiveModel.err
|
||||
}
|
||||
if effectiveModel.decision.Warning {
|
||||
sm.publishModelSelectionWarningEvent(execution, result.SessionID, effectiveModel.decision)
|
||||
}
|
||||
// The layers apply the policy-decided model; the runtime override is
|
||||
// neutralized because the decision already accounted for it. When the
|
||||
// policy ran it already applied the model via SetModel — the layers must
|
||||
@@ -426,13 +428,8 @@ func (sm *SessionManager) InitializeAndPromptWithLayers(
|
||||
|
||||
// applyStartModelPolicyToEffectiveModel resolves the effective model (profile
|
||||
// start model, overridden by the persisted runtime model) and applies the
|
||||
// profile's no-silent-model-fallback policy to it: strict profiles fail a gone
|
||||
// model explicitly, fallback-model profiles switch to the configured fallback
|
||||
// (publishing the fallback event), and auto-fallback keeps legacy behavior.
|
||||
// Returns the decided model (fallback when one was applied), the model
|
||||
// actually applied by the policy, whether the policy handled model selection,
|
||||
// or the policy error. The caller neutralizes the runtime override afterwards
|
||||
// because the decision already accounted for it.
|
||||
// executor-authoritative policy. The caller neutralizes the runtime override
|
||||
// afterwards because the decision already accounted for it.
|
||||
func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
|
||||
ctx context.Context,
|
||||
execution *AgentExecution,
|
||||
@@ -443,6 +440,7 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
|
||||
model string
|
||||
appliedModel string
|
||||
handled bool
|
||||
decision ModelSelectionDecision
|
||||
err error
|
||||
}) {
|
||||
effective.model = profileModel
|
||||
@@ -457,7 +455,7 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
|
||||
// profile layers must not retry an outcome that was already handled.
|
||||
effective.handled = true
|
||||
startModelPolicy.Model = effective.model
|
||||
appliedModel, usingFallback, policyErr := applyStartModelPolicy(
|
||||
decision, policyErr := applyStartModelPolicy(
|
||||
ctx, sm.logger, execution.agentctl,
|
||||
execution.GetModelState(), startModelPolicy,
|
||||
)
|
||||
@@ -468,10 +466,13 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel(
|
||||
effective.err = policyErr
|
||||
return effective
|
||||
}
|
||||
effective.appliedModel = appliedModel
|
||||
if usingFallback {
|
||||
sm.publishModelFallbackEvent(execution, acpSessionID, appliedModel)
|
||||
effective.model = appliedModel
|
||||
effective.decision = decision
|
||||
if decision.Outcome == ModelSelectionOutcomeApplied ||
|
||||
decision.Outcome == ModelSelectionOutcomeExplicitFallback {
|
||||
effective.appliedModel = decision.EffectiveModel
|
||||
}
|
||||
if decision.Outcome == ModelSelectionOutcomeExplicitFallback {
|
||||
effective.model = decision.EffectiveModel
|
||||
}
|
||||
return effective
|
||||
}
|
||||
@@ -700,6 +701,48 @@ func (sm *SessionManager) publishModelFallbackEvent(
|
||||
})
|
||||
}
|
||||
|
||||
// publishModelSelectionWarningEvent emits the provider-neutral decision and
|
||||
// keeps the legacy fallback event as a compatibility projection for clients
|
||||
// that still listen for session_model_fallback.
|
||||
func (sm *SessionManager) publishModelSelectionWarningEvent(
|
||||
execution *AgentExecution,
|
||||
acpSessionID string,
|
||||
decision ModelSelectionDecision,
|
||||
) {
|
||||
if sm.eventPublisher == nil || execution == nil || !decision.Warning {
|
||||
return
|
||||
}
|
||||
decisionIDBytes := sha256.Sum256([]byte(strings.Join([]string{
|
||||
execution.ID,
|
||||
acpSessionID,
|
||||
decision.RequestedModel,
|
||||
decision.Reason,
|
||||
decision.EffectiveModel,
|
||||
}, "|")))
|
||||
warning := &streams.ModelSelectionWarning{
|
||||
Kind: "model_selection_warning",
|
||||
DecisionID: hex.EncodeToString(decisionIDBytes[:]),
|
||||
Reason: decision.Reason,
|
||||
RequestedModel: decision.RequestedModel,
|
||||
EffectiveModel: decision.EffectiveModel,
|
||||
AgentID: execution.AgentID,
|
||||
ExecutorType: string(execution.RuntimeName),
|
||||
ExecutorProfileID: execution.metadataString(MetadataKeyExecutorProfileID),
|
||||
}
|
||||
if decision.Outcome == ModelSelectionOutcomeExplicitFallback {
|
||||
warning.FallbackModel = decision.EffectiveModel
|
||||
}
|
||||
sm.eventPublisher.PublishAgentStreamEvent(execution, agentctl.AgentEvent{
|
||||
Type: streams.EventTypeSessionModelSelectionWarning,
|
||||
SessionID: acpSessionID,
|
||||
CurrentModelID: warning.EffectiveModel,
|
||||
ModelSelectionWarning: warning,
|
||||
})
|
||||
if warning.FallbackModel != "" {
|
||||
sm.publishModelFallbackEvent(execution, acpSessionID, warning.FallbackModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) publishWorkflowSessionConfigFailures(
|
||||
execution *AgentExecution,
|
||||
acpSessionID string,
|
||||
|
||||
@@ -239,14 +239,10 @@ func createTestClient(t *testing.T, serverURL string) *agentctl.Client {
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
// TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel verifies the
|
||||
// start-model policy is applied to the EFFECTIVE model (profile start model or
|
||||
// persisted runtime override) and a strict profile fails the launch explicitly
|
||||
// when that model is unavailable. This is the regression guard for the
|
||||
// no-silent model-fallback gate: an unavailable model must never silently
|
||||
// continue on the provider default, and a failed launch must not look
|
||||
// initialized.
|
||||
func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T) {
|
||||
// TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault
|
||||
// verifies the executor-authoritative policy for both the profile model and a
|
||||
// persisted runtime override.
|
||||
func TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
profileModel string
|
||||
@@ -266,7 +262,8 @@ func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T)
|
||||
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
|
||||
}, nil, stopCh)
|
||||
cleanupStreamManager(t, stopCh, streamMgr)
|
||||
sm.SetDependencies(NewEventPublisher(&MockEventBusWithTracking{}, log), streamMgr, nil, nil)
|
||||
eventBus := &MockEventBusWithTracking{}
|
||||
sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil)
|
||||
|
||||
client := createTestClient(t, mock.server.URL)
|
||||
defer client.Close()
|
||||
@@ -300,14 +297,29 @@ func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T)
|
||||
tc.runtimeModel, "", nil,
|
||||
StartModelPolicy{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected InitializeAndPromptWithLayers to fail for an unavailable model")
|
||||
if err != nil {
|
||||
t.Fatalf("unadvertised model should not fail launch: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "claude-gone") {
|
||||
t.Errorf("error should name the unavailable model, got %q", err.Error())
|
||||
if !execution.sessionInitialized {
|
||||
t.Error("provider-default continuation must be marked initialized")
|
||||
}
|
||||
if execution.sessionInitialized {
|
||||
t.Error("a failed launch must not be marked initialized")
|
||||
for _, action := range mock.getActionLog() {
|
||||
if action == "agent.session.set_model" {
|
||||
t.Error("unadvertised model must not send a SetModel request")
|
||||
}
|
||||
}
|
||||
var warning *AgentStreamEventPayload
|
||||
for _, event := range eventBus.getStreamEvents() {
|
||||
if event.Data != nil && event.Data.ModelSelectionWarning != nil {
|
||||
warning = &event
|
||||
break
|
||||
}
|
||||
}
|
||||
if warning == nil {
|
||||
t.Fatal("expected a model-selection warning event")
|
||||
}
|
||||
if warning.Data.ModelSelectionWarning.RequestedModel != "claude-gone" {
|
||||
t.Errorf("warning requested model = %q, want claude-gone", warning.Data.ModelSelectionWarning.RequestedModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -594,7 +606,6 @@ func TestInitializeAndPrompt_AppliesProfileConfigOptions(t *testing.T) {
|
||||
|
||||
actions := mock.getActionLog()
|
||||
for _, want := range []string{
|
||||
"agent.session.set_model",
|
||||
"agent.session.set_mode",
|
||||
"agent.session.set_config_option",
|
||||
} {
|
||||
|
||||
@@ -2,30 +2,60 @@ package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
||||
"github.com/kandev/kandev/internal/agentctl/sessionmodel"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// StartModelPolicy carries the profile's model-selection settings for the
|
||||
// no-silent-model-fallback policy applied at session start (and when a
|
||||
// context reset re-applies the effective model to a fresh ACP session).
|
||||
// executor-authoritative policy applied at session start and when a fresh ACP
|
||||
// session is created.
|
||||
type StartModelPolicy struct {
|
||||
// Model is the configured start model (ACP model ID). Empty means "use
|
||||
// the agent's default" — nothing is applied.
|
||||
// the agent's default" and does not produce a selection decision.
|
||||
Model string
|
||||
// FallbackModel is the optional single model to switch to when Model is
|
||||
// unavailable. Ignored when AutoFallback is enabled.
|
||||
// not advertised. It is used only when the executor advertises it.
|
||||
FallbackModel string
|
||||
// AutoFallback restores the legacy best-effort behavior: SetModel
|
||||
// failures log and the session continues on the provider default.
|
||||
// AutoFallback keeps selection best-effort after an advertised model
|
||||
// rejects SetModel. It does not bypass the executor catalog.
|
||||
AutoFallback bool
|
||||
}
|
||||
|
||||
// ModelSelectionOutcome describes the executor-authoritative result.
|
||||
type ModelSelectionOutcome string
|
||||
|
||||
const (
|
||||
ModelSelectionOutcomeNone ModelSelectionOutcome = ""
|
||||
ModelSelectionOutcomeApplied ModelSelectionOutcome = "applied"
|
||||
ModelSelectionOutcomeExplicitFallback ModelSelectionOutcome = "explicit_fallback"
|
||||
ModelSelectionOutcomeProviderDefault ModelSelectionOutcome = "provider_default"
|
||||
)
|
||||
|
||||
// Reasons are stable values stored in warning metadata and consumed by the
|
||||
// frontend. Keep them provider-neutral.
|
||||
const (
|
||||
ModelSelectionReasonRequestedNotAdvertised = "requested_not_advertised"
|
||||
ModelSelectionReasonFallbackNotAdvertised = "fallback_not_advertised"
|
||||
ModelSelectionReasonCatalogEmpty = "catalog_empty"
|
||||
ModelSelectionReasonSelectionUnsupported = "selection_unsupported"
|
||||
ModelSelectionReasonSelectionFailedAutoFallback = "selection_failed_auto_fallback"
|
||||
)
|
||||
|
||||
// ModelSelectionDecision is the single model-selection result shared by
|
||||
// initial launch, context reset, and workspace rebind.
|
||||
type ModelSelectionDecision struct {
|
||||
RequestedModel string `json:"requested_model,omitempty"`
|
||||
FallbackModel string `json:"fallback_model,omitempty"`
|
||||
EffectiveModel string `json:"effective_model,omitempty"`
|
||||
Outcome ModelSelectionOutcome `json:"outcome"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Warning bool `json:"warning"`
|
||||
SetModelCalled bool `json:"set_model_called"`
|
||||
}
|
||||
|
||||
// modelApplier abstracts the agentctl surface applyStartModelPolicy needs so
|
||||
// tests can script SetModel without a live agentctl server.
|
||||
type modelApplier interface {
|
||||
@@ -33,7 +63,7 @@ type modelApplier interface {
|
||||
}
|
||||
|
||||
// advertisedModelIDs returns the session's currently advertised model IDs.
|
||||
// An empty slice means the agent has not advertised a model list (unknown).
|
||||
// An empty slice means the executor has no usable model catalog.
|
||||
func advertisedModelIDs(state *CachedModelState) []string {
|
||||
if state == nil {
|
||||
return nil
|
||||
@@ -45,8 +75,6 @@ func advertisedModelIDs(state *CachedModelState) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// containsModel reports whether ids contains id.
|
||||
|
||||
func containsModel(ids []string, id string) bool {
|
||||
for _, existing := range ids {
|
||||
if existing == id {
|
||||
@@ -56,91 +84,104 @@ func containsModel(ids []string, id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// applyFallbackModel applies the policy's fallback model after the start
|
||||
// model proved unavailable (gone from the advertised list or rejected by
|
||||
// SetModel). Shared by both failure branches so the log line, error
|
||||
// wrapping, and return shape stay in sync. reason ("is unavailable" /
|
||||
// "failed") distinguishes the failure context in the error text.
|
||||
func applyFallbackModel(
|
||||
ctx context.Context,
|
||||
log *logger.Logger,
|
||||
applier modelApplier,
|
||||
policy StartModelPolicy,
|
||||
reason string,
|
||||
) (string, bool, error) {
|
||||
if err := applier.SetModel(ctx, policy.FallbackModel); err != nil {
|
||||
return "", false, fmt.Errorf(
|
||||
"start model %q %s and fallback model %q failed to apply: %w",
|
||||
policy.Model, reason, policy.FallbackModel, err)
|
||||
func providerDefaultDecision(state *CachedModelState, policy StartModelPolicy, reason string) ModelSelectionDecision {
|
||||
decision := ModelSelectionDecision{
|
||||
RequestedModel: policy.Model,
|
||||
FallbackModel: policy.FallbackModel,
|
||||
Outcome: ModelSelectionOutcomeProviderDefault,
|
||||
Reason: reason,
|
||||
Warning: policy.Model != "",
|
||||
}
|
||||
log.Info("start model unavailable, using fallback model",
|
||||
zap.String("start_model", policy.Model),
|
||||
zap.String("fallback_model", policy.FallbackModel))
|
||||
return policy.FallbackModel, true, nil
|
||||
if state != nil {
|
||||
decision.EffectiveModel = state.CurrentModelID
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
// applyStartModelPolicy enforces the no-silent-model-fallback rules when
|
||||
// applying the profile's start model to a session:
|
||||
//
|
||||
// - AutoFallback: legacy best-effort (attempt SetModel; on failure warn
|
||||
// and continue on the provider default).
|
||||
// - Start model gone (absent from the advertised list, when known):
|
||||
// apply FallbackModel when set (returning usingFallback=true), else
|
||||
// fail with the actionable model-unavailable message.
|
||||
// - Start model present / list unknown: apply it; a method-not-found
|
||||
// error means the agent has no model selection — continue silently;
|
||||
// any other error fails explicitly.
|
||||
//
|
||||
// Returns the model actually applied ("" when none was applied / the agent
|
||||
// default remains) and whether the fallback model was used.
|
||||
// applyStartModelPolicy applies the profile model only when the executor's
|
||||
// ACP catalog advertises it. If the requested model is absent, it may apply an
|
||||
// explicitly configured fallback only when that fallback is also advertised.
|
||||
// All other mismatches continue on the agent's current/default model and
|
||||
// return a warning decision. Errors from an advertised model remain explicit,
|
||||
// except for method-not-supported and legacy auto-fallback mode.
|
||||
func applyStartModelPolicy(
|
||||
ctx context.Context,
|
||||
log *logger.Logger,
|
||||
applier modelApplier,
|
||||
state *CachedModelState,
|
||||
policy StartModelPolicy,
|
||||
) (applied string, usingFallback bool, err error) {
|
||||
) (ModelSelectionDecision, error) {
|
||||
if policy.Model == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if policy.AutoFallback {
|
||||
if err := applier.SetModel(ctx, policy.Model); err != nil {
|
||||
log.Warn("failed to set profile model via ACP (auto-fallback)",
|
||||
zap.String("model", policy.Model), zap.Error(err))
|
||||
return "", false, nil
|
||||
}
|
||||
return policy.Model, false, nil
|
||||
return ModelSelectionDecision{Outcome: ModelSelectionOutcomeNone}, nil
|
||||
}
|
||||
|
||||
decision := ModelSelectionDecision{
|
||||
RequestedModel: policy.Model,
|
||||
FallbackModel: policy.FallbackModel,
|
||||
}
|
||||
advertised := advertisedModelIDs(state)
|
||||
if len(advertised) > 0 && !containsModel(advertised, policy.Model) {
|
||||
if policy.FallbackModel != "" {
|
||||
return applyFallbackModel(ctx, log, applier, policy, "is unavailable")
|
||||
}
|
||||
return "", false, fmt.Errorf("%s", routingerr.ModelUnavailableMessage(policy.Model))
|
||||
if len(advertised) == 0 {
|
||||
return providerDefaultDecision(state, policy, ModelSelectionReasonCatalogEmpty), nil
|
||||
}
|
||||
|
||||
if !containsModel(advertised, policy.Model) {
|
||||
if policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) {
|
||||
return applyAdvertisedFallback(ctx, log, applier, state, policy, decision)
|
||||
}
|
||||
reason := ModelSelectionReasonRequestedNotAdvertised
|
||||
if policy.FallbackModel != "" {
|
||||
reason = ModelSelectionReasonFallbackNotAdvertised
|
||||
}
|
||||
return providerDefaultDecision(state, policy, reason), nil
|
||||
}
|
||||
|
||||
decision.SetModelCalled = true
|
||||
if err := applier.SetModel(ctx, policy.Model); err != nil {
|
||||
if sessionmodel.IsMethodNotFound(err) {
|
||||
// The agent declares no model-selection support — nothing to
|
||||
// enforce; the session continues on the agent default.
|
||||
log.Debug("agent does not support model selection, continuing",
|
||||
log.Debug("agent does not support model selection, continuing on provider default",
|
||||
zap.String("model", policy.Model), zap.Error(err))
|
||||
return "", false, nil
|
||||
decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported)
|
||||
decision.SetModelCalled = true
|
||||
return decision, nil
|
||||
}
|
||||
// SetModel failed even though the model appeared available (the
|
||||
// advertised list may not have arrived yet at session start, or the
|
||||
// agent rejected the model itself). The explicit fallback exists
|
||||
// exactly for this: switch to it instead of failing — but never
|
||||
// fall through to the provider default. A transport or cancellation
|
||||
// failure is NOT evidence the model is unavailable — switching
|
||||
// models would mask a broken connection, so those fail explicitly
|
||||
// like any other non-availability error.
|
||||
if policy.FallbackModel != "" &&
|
||||
!isTransportDeadErr(err) && !errors.Is(err, context.Canceled) {
|
||||
return applyFallbackModel(ctx, log, applier, policy, "failed")
|
||||
if policy.AutoFallback {
|
||||
log.Warn("failed to set profile model via ACP (auto-fallback)",
|
||||
zap.String("model", policy.Model), zap.Error(err))
|
||||
decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionFailedAutoFallback)
|
||||
decision.SetModelCalled = true
|
||||
return decision, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
|
||||
return decision, fmt.Errorf("failed to set start model %q: %w", policy.Model, err)
|
||||
}
|
||||
return policy.Model, false, nil
|
||||
|
||||
decision.EffectiveModel = policy.Model
|
||||
decision.Outcome = ModelSelectionOutcomeApplied
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func applyAdvertisedFallback(
|
||||
ctx context.Context,
|
||||
log *logger.Logger,
|
||||
applier modelApplier,
|
||||
state *CachedModelState,
|
||||
policy StartModelPolicy,
|
||||
decision ModelSelectionDecision,
|
||||
) (ModelSelectionDecision, error) {
|
||||
decision.SetModelCalled = true
|
||||
if err := applier.SetModel(ctx, policy.FallbackModel); err != nil {
|
||||
if sessionmodel.IsMethodNotFound(err) {
|
||||
decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported)
|
||||
decision.SetModelCalled = true
|
||||
return decision, nil
|
||||
}
|
||||
return decision, fmt.Errorf("failed to set fallback model %q: %w", policy.FallbackModel, err)
|
||||
}
|
||||
decision.EffectiveModel = policy.FallbackModel
|
||||
decision.Outcome = ModelSelectionOutcomeExplicitFallback
|
||||
decision.Reason = ModelSelectionReasonRequestedNotAdvertised
|
||||
decision.Warning = true
|
||||
log.Info("start model unavailable, using advertised fallback model",
|
||||
zap.String("start_model", policy.Model),
|
||||
zap.String("fallback_model", policy.FallbackModel))
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state *CachedModelState
|
||||
policy StartModelPolicy
|
||||
applierErrors []error
|
||||
wantCalls []string
|
||||
wantOutcome ModelSelectionOutcome
|
||||
wantReason string
|
||||
wantEffective string
|
||||
wantWarning bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "requested model absent does not call executor",
|
||||
state: modelState("executor-default"),
|
||||
policy: StartModelPolicy{Model: "host-only-model"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonRequestedNotAdvertised,
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "empty catalog does not call executor",
|
||||
state: &CachedModelState{},
|
||||
policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonCatalogEmpty,
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "advertised fallback is applied",
|
||||
state: modelState("fallback"),
|
||||
policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"},
|
||||
wantCalls: []string{"fallback"},
|
||||
wantOutcome: ModelSelectionOutcomeExplicitFallback,
|
||||
wantReason: ModelSelectionReasonRequestedNotAdvertised,
|
||||
wantEffective: "fallback",
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "advertised fallback method not supported keeps provider default",
|
||||
state: modelState("fallback"),
|
||||
policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"},
|
||||
applierErrors: []error{methodNotFoundErr()},
|
||||
wantCalls: []string{"fallback"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonSelectionUnsupported,
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "unadvertised fallback keeps provider default",
|
||||
state: modelState("executor-default"),
|
||||
policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "host-fallback"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonFallbackNotAdvertised,
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "advertised requested model is applied",
|
||||
state: modelState("requested"),
|
||||
policy: StartModelPolicy{Model: "requested"},
|
||||
wantCalls: []string{"requested"},
|
||||
wantOutcome: ModelSelectionOutcomeApplied,
|
||||
wantEffective: "requested",
|
||||
},
|
||||
{
|
||||
name: "method not supported keeps provider default",
|
||||
state: modelState("requested"),
|
||||
policy: StartModelPolicy{Model: "requested"},
|
||||
applierErrors: []error{methodNotFoundErr()},
|
||||
wantCalls: []string{"requested"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonSelectionUnsupported,
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "advertised apply error is explicit",
|
||||
state: modelState("requested"),
|
||||
policy: StartModelPolicy{Model: "requested", FallbackModel: "fallback"},
|
||||
applierErrors: []error{errors.New("rejected")},
|
||||
wantCalls: []string{"requested"},
|
||||
wantErr: `failed to set start model "requested"`,
|
||||
},
|
||||
{
|
||||
name: "auto fallback warns after advertised apply error",
|
||||
state: modelState("requested"),
|
||||
policy: StartModelPolicy{Model: "requested", AutoFallback: true},
|
||||
applierErrors: []error{errors.New("rejected")},
|
||||
wantCalls: []string{"requested"},
|
||||
wantOutcome: ModelSelectionOutcomeProviderDefault,
|
||||
wantReason: ModelSelectionReasonSelectionFailedAutoFallback,
|
||||
wantWarning: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
applier := &fakeModelApplier{errs: tt.applierErrors}
|
||||
decision, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier, tt.state, tt.policy,
|
||||
)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(applier.calls, tt.wantCalls) {
|
||||
t.Errorf("SetModel calls = %v, want %v", applier.calls, tt.wantCalls)
|
||||
}
|
||||
if decision.Outcome != tt.wantOutcome {
|
||||
t.Errorf("outcome = %q, want %q", decision.Outcome, tt.wantOutcome)
|
||||
}
|
||||
if decision.Reason != tt.wantReason {
|
||||
t.Errorf("reason = %q, want %q", decision.Reason, tt.wantReason)
|
||||
}
|
||||
if decision.EffectiveModel != tt.wantEffective {
|
||||
t.Errorf("effective model = %q, want %q", decision.EffectiveModel, tt.wantEffective)
|
||||
}
|
||||
if decision.Warning != tt.wantWarning {
|
||||
t.Errorf("warning = %v, want %v", decision.Warning, tt.wantWarning)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,17 @@ package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// fakeModelApplier records SetModel calls and returns scripted errors, one
|
||||
// per call; the last error repeats for any further calls.
|
||||
type fakeModelApplier struct {
|
||||
calls []string
|
||||
errs []error
|
||||
}
|
||||
|
||||
// SetModel records the applied model so tests can assert what the policy sent.
|
||||
|
||||
func (f *fakeModelApplier) SetModel(_ context.Context, modelID string) error {
|
||||
f.calls = append(f.calls, modelID)
|
||||
if len(f.errs) == 0 {
|
||||
@@ -32,15 +25,11 @@ func (f *fakeModelApplier) SetModel(_ context.Context, modelID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// newPolicyTestLogger returns a discard logger for policy tests.
|
||||
|
||||
func newPolicyTestLogger() *logger.Logger {
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
return log
|
||||
}
|
||||
|
||||
// modelState builds a cached model state advertising the given model ids.
|
||||
|
||||
func modelState(models ...string) *CachedModelState {
|
||||
infos := make([]streams.SessionModelInfo, 0, len(models))
|
||||
for _, id := range models {
|
||||
@@ -49,228 +38,6 @@ func modelState(models ...string) *CachedModelState {
|
||||
return &CachedModelState{Models: infos}
|
||||
}
|
||||
|
||||
// methodNotFoundErr simulates the agent declaring no model-selection support.
|
||||
|
||||
func methodNotFoundErr() error {
|
||||
return &acp.RequestError{Code: -32601, Message: "method not found"}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_StrictGoneModelFails verifies a gone start model fails the session with the actionable message.
|
||||
|
||||
func TestApplyStartModelPolicy_StrictGoneModelFails(t *testing.T) {
|
||||
applier := &fakeModelApplier{}
|
||||
_, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for gone start model in strict mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "claude-gone") {
|
||||
t.Errorf("error should name the gone model, got %q", err.Error())
|
||||
}
|
||||
if usingFallback {
|
||||
t.Error("usingFallback should be false")
|
||||
}
|
||||
if len(applier.calls) != 0 {
|
||||
t.Errorf("SetModel should not be called for a gone model in strict mode, got %v", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_StrictGoneModelNoSession verifies no model is applied when the strict policy rejects a gone start model.
|
||||
|
||||
func TestApplyStartModelPolicy_StrictGoneModelNoSession(t *testing.T) {
|
||||
// Empty advertised list = "unknown" — fall back to SetModel result.
|
||||
applier := &fakeModelApplier{errs: []error{errors.New("model rejected")}}
|
||||
_, _, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
&CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SetModel fails and list is unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues verifies a method-not-found SetModel error continues silently.
|
||||
|
||||
func TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues(t *testing.T) {
|
||||
applier := &fakeModelApplier{errs: []error{methodNotFoundErr()}}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
&CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("method-not-found should continue silently, got %v", err)
|
||||
}
|
||||
if applied != "" || usingFallback {
|
||||
t.Errorf("expected no applied model, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_StrictModelPresentApplies verifies an advertised start model is applied.
|
||||
|
||||
func TestApplyStartModelPolicy_StrictModelPresentApplies(t *testing.T) {
|
||||
applier := &fakeModelApplier{}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if applied != "claude-sonnet" || usingFallback {
|
||||
t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
if len(applier.calls) != 1 || applier.calls[0] != "claude-sonnet" {
|
||||
t.Errorf("SetModel calls = %v, want [claude-sonnet]", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_FallbackModelUsedWhenGone verifies the configured fallback replaces a gone start model.
|
||||
|
||||
func TestApplyStartModelPolicy_FallbackModelUsedWhenGone(t *testing.T) {
|
||||
applier := &fakeModelApplier{}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if applied != "gpt-5" || !usingFallback {
|
||||
t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
if len(applier.calls) != 1 || applier.calls[0] != "gpt-5" {
|
||||
t.Errorf("SetModel calls = %v, want [gpt-5]", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel verifies the fallback applies when SetModel rejects the start model even without an advertised list.
|
||||
|
||||
func TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel(t *testing.T) {
|
||||
// Regression: at session start the advertised model list may not have
|
||||
// arrived yet (empty = "unknown"). The adapter still rejects the gone
|
||||
// start model — the fallback must be applied then too, not only when
|
||||
// the list is known.
|
||||
applier := &fakeModelApplier{errs: []error{
|
||||
errors.New(`model "claude-gone" is not in the agent's 2 available models`),
|
||||
nil,
|
||||
}}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
&CachedModelState{}, StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if applied != "gpt-5" || !usingFallback {
|
||||
t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
if len(applier.calls) != 2 || applier.calls[0] != "claude-gone" || applier.calls[1] != "gpt-5" {
|
||||
t.Errorf("SetModel calls = %v, want [claude-gone gpt-5]", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_FallbackModelFailureFails verifies a failed fallback SetModel fails the session explicitly.
|
||||
|
||||
func TestApplyStartModelPolicy_FallbackModelFailureFails(t *testing.T) {
|
||||
applier := &fakeModelApplier{errs: []error{errors.New("rejected")}}
|
||||
_, _, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when the fallback model itself fails to apply")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "gpt-5") {
|
||||
t.Errorf("error should name the fallback model, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort verifies auto-fallback logs and continues on SetModel failure.
|
||||
|
||||
func TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort(t *testing.T) {
|
||||
// Gone model + auto-fallback: legacy behavior — try, warn, continue.
|
||||
applier := &fakeModelApplier{errs: []error{errors.New("not in available models")}}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", AutoFallback: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("auto-fallback should never fail: %v", err)
|
||||
}
|
||||
if applied != "" || usingFallback {
|
||||
t.Errorf("expected applied=\"\" fallback=false, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
if len(applier.calls) != 1 {
|
||||
t.Errorf("legacy path should still attempt SetModel, calls=%v", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_AutoFallbackPresentApplies verifies auto-fallback still applies an available start model.
|
||||
|
||||
func TestApplyStartModelPolicy_AutoFallbackPresentApplies(t *testing.T) {
|
||||
applier := &fakeModelApplier{}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet", AutoFallback: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if applied != "claude-sonnet" || usingFallback {
|
||||
t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_EmptyModelNoOp verifies an empty start model is a no-op.
|
||||
|
||||
func TestApplyStartModelPolicy_EmptyModelNoOp(t *testing.T) {
|
||||
applier := &fakeModelApplier{}
|
||||
applied, usingFallback, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("gpt-5"), StartModelPolicy{},
|
||||
)
|
||||
if err != nil || applied != "" || usingFallback {
|
||||
t.Errorf("empty model should no-op, got applied=%q fallback=%v err=%v", applied, usingFallback, err)
|
||||
}
|
||||
if len(applier.calls) != 0 {
|
||||
t.Errorf("SetModel should not be called for empty model, got %v", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_TransportErrorDoesNotFallback verifies a
|
||||
// transport/protocol SetModel failure fails explicitly instead of switching
|
||||
// to the fallback: a broken connection is not evidence the model is
|
||||
// unavailable, so applying the fallback would mask the real fault.
|
||||
func TestApplyStartModelPolicy_TransportErrorDoesNotFallback(t *testing.T) {
|
||||
applier := &fakeModelApplier{errs: []error{
|
||||
errors.New(`websocket: connection closed: peer disconnected`),
|
||||
}}
|
||||
_, _, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected explicit failure for a transport error")
|
||||
}
|
||||
if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" {
|
||||
t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStartModelPolicy_CancelErrorDoesNotFallback verifies a cancelled
|
||||
// SetModel context does not switch models either.
|
||||
func TestApplyStartModelPolicy_CancelErrorDoesNotFallback(t *testing.T) {
|
||||
applier := &fakeModelApplier{errs: []error{context.Canceled}}
|
||||
_, _, err := applyStartModelPolicy(
|
||||
context.Background(), newPolicyTestLogger(), applier,
|
||||
modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected explicit failure for a cancelled SetModel")
|
||||
}
|
||||
if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" {
|
||||
t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,14 @@ type ModelSettableAdapter interface {
|
||||
SetModel(ctx context.Context, modelID string) error
|
||||
}
|
||||
|
||||
// SessionModelStateProvider exposes the model catalog returned by the most
|
||||
// recent session creation or load. The agent stream still emits the same
|
||||
// session_models event, but this synchronous snapshot prevents lifecycle
|
||||
// decisions from racing that event's downstream dispatch.
|
||||
type SessionModelStateProvider interface {
|
||||
GetSessionModelState() *streams.SessionModelState
|
||||
}
|
||||
|
||||
// AuthenticatableAdapter is an optional interface implemented by adapters that
|
||||
// support ACP authentication (session/authenticate).
|
||||
type AuthenticatableAdapter interface {
|
||||
|
||||
@@ -533,6 +533,49 @@ func (a *Adapter) GetSessionID() string {
|
||||
return a.sessionID
|
||||
}
|
||||
|
||||
// GetSessionModelState returns the latest session model snapshot while holding
|
||||
// the adapter lock. The snapshot is used in the synchronous session response;
|
||||
// the normal session_models stream event remains the long-lived cache update.
|
||||
func (a *Adapter) GetSessionModelState() *streams.SessionModelState {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if len(a.availableModels) == 0 && len(a.availableConfigOptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &streams.SessionModelState{
|
||||
CurrentModelID: currentModelFromConfig(a.availableConfigOptions),
|
||||
Models: cloneSessionModels(convertSessionModels(a.availableModels)),
|
||||
ConfigOptions: cloneConfigOptions(a.availableConfigOptions),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSessionModels(models []streams.SessionModelInfo) []streams.SessionModelInfo {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := append([]streams.SessionModelInfo(nil), models...)
|
||||
for i, model := range cloned {
|
||||
if model.Meta != nil {
|
||||
cloned[i].Meta = make(map[string]any, len(model.Meta))
|
||||
for key, value := range model.Meta {
|
||||
cloned[i].Meta[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneConfigOptions(options []streams.ConfigOption) []streams.ConfigOption {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := append([]streams.ConfigOption(nil), options...)
|
||||
for i, option := range cloned {
|
||||
cloned[i].Options = append([]streams.ConfigOptionValue(nil), option.Options...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// GetOperationID returns the current operation/turn ID.
|
||||
// ACP protocol doesn't have explicit turn/operation IDs, so this returns empty string.
|
||||
func (a *Adapter) GetOperationID() string {
|
||||
|
||||
@@ -59,9 +59,10 @@ type NewSessionRequest struct {
|
||||
|
||||
// NewSessionResponse is the response to a new session call
|
||||
type NewSessionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ModelState *streams.SessionModelState `json:"model_state,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LoadSessionRequest is a request to load an existing ACP session
|
||||
@@ -72,9 +73,10 @@ type LoadSessionRequest struct {
|
||||
|
||||
// LoadSessionResponse is the response to a load session call
|
||||
type LoadSessionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ModelState *streams.SessionModelState `json:"model_state,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PromptRequest is a request to send a prompt to the agent
|
||||
@@ -469,8 +471,9 @@ func (s *Server) handleWSNewSession(ctx context.Context, msg *ws.Message) *ws.Me
|
||||
}
|
||||
|
||||
resp, _ := ws.NewResponse(msg.ID, msg.Action, NewSessionResponse{
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
ModelState: sessionModelState(adapter),
|
||||
})
|
||||
return resp
|
||||
}
|
||||
@@ -519,8 +522,9 @@ func (s *Server) handleWSLoadSession(ctx context.Context, msg *ws.Message) *ws.M
|
||||
s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, nil)
|
||||
|
||||
resp, _ := ws.NewResponse(msg.ID, msg.Action, LoadSessionResponse{
|
||||
Success: true,
|
||||
SessionID: req.SessionID,
|
||||
Success: true,
|
||||
SessionID: req.SessionID,
|
||||
ModelState: sessionModelState(adapter),
|
||||
})
|
||||
return resp
|
||||
}
|
||||
@@ -766,12 +770,21 @@ func (s *Server) handleWSResetSession(ctx context.Context, msg *ws.Message) *ws.
|
||||
}
|
||||
|
||||
resp, _ := ws.NewResponse(msg.ID, msg.Action, NewSessionResponse{
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
ModelState: sessionModelState(agentAdapter),
|
||||
})
|
||||
return resp
|
||||
}
|
||||
|
||||
func sessionModelState(agentAdapter adapter.AgentAdapter) *streams.SessionModelState {
|
||||
provider, ok := agentAdapter.(adapter.SessionModelStateProvider)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return provider.GetSessionModelState()
|
||||
}
|
||||
|
||||
// promptOrSteer routes a prompt to the steering path when the caller asked for it
|
||||
// and the adapter can actually do it. An adapter that does not implement
|
||||
// SteerablePrompter, or a connected agent that never advertised the capability,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
@@ -56,6 +57,7 @@ type capableAgentAdapter struct {
|
||||
resetServers []types.McpServer
|
||||
resetErr error
|
||||
failWith error
|
||||
modelState *streams.SessionModelState
|
||||
}
|
||||
|
||||
func (a *capableAgentAdapter) SetMode(_ context.Context, modeID string) error {
|
||||
@@ -73,6 +75,10 @@ func (a *capableAgentAdapter) Authenticate(_ context.Context, methodID string) e
|
||||
return a.failWith
|
||||
}
|
||||
|
||||
func (a *capableAgentAdapter) GetSessionModelState() *streams.SessionModelState {
|
||||
return a.modelState
|
||||
}
|
||||
|
||||
func (a *capableAgentAdapter) ResetSession(_ context.Context, servers []types.McpServer) (string, error) {
|
||||
a.resetServers = servers
|
||||
if a.resetErr != nil {
|
||||
@@ -275,6 +281,10 @@ func TestHandleWSResetSession_ReturnsNewSessionID(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
agentAdapter := &capableAgentAdapter{}
|
||||
agentAdapter.sessionID = "session-before-reset"
|
||||
agentAdapter.modelState = &streams.SessionModelState{
|
||||
CurrentModelID: "mock-fast",
|
||||
Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}},
|
||||
}
|
||||
s.procMgr.SetAdapterForTest(agentAdapter)
|
||||
|
||||
msg, err := ws.NewRequest("req-1", "agent.session.reset", NewSessionRequest{
|
||||
@@ -294,6 +304,9 @@ func TestHandleWSResetSession_ReturnsNewSessionID(t *testing.T) {
|
||||
if !payload.Success || payload.SessionID != "session-after-reset" {
|
||||
t.Errorf("payload = %+v, want the adapter's new session id", payload)
|
||||
}
|
||||
if payload.ModelState == nil || payload.ModelState.CurrentModelID != "mock-fast" || len(payload.ModelState.Models) != 2 {
|
||||
t.Errorf("model state = %+v, want the adapter's synchronous catalog", payload.ModelState)
|
||||
}
|
||||
if len(agentAdapter.resetServers) != 1 || agentAdapter.resetServers[0].Name != "external" {
|
||||
t.Errorf("adapter received %+v, want the caller's MCP servers", agentAdapter.resetServers)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ const (
|
||||
// unavailable. Data carries {"fallback_model": <model id>}.
|
||||
EventTypeSessionModelFallback = "session_model_fallback"
|
||||
|
||||
// EventTypeSessionModelSelectionWarning explains an executor-authoritative
|
||||
// default or explicit fallback decision.
|
||||
EventTypeSessionModelSelectionWarning = "session_model_selection_warning"
|
||||
|
||||
// EventTypeSessionInfo indicates ACP session metadata such as title changed.
|
||||
EventTypeSessionInfo = "session_info"
|
||||
|
||||
@@ -303,6 +307,10 @@ type AgentEvent struct {
|
||||
// events).
|
||||
FallbackModel string `json:"fallback_model,omitempty"`
|
||||
|
||||
// ModelSelectionWarning carries the provider-neutral model decision made
|
||||
// before the session's first prompt.
|
||||
ModelSelectionWarning *ModelSelectionWarning `json:"model_selection_warning,omitempty"`
|
||||
|
||||
// SessionModels lists models available in the ACP session.
|
||||
SessionModels []SessionModelInfo `json:"session_models,omitempty"`
|
||||
|
||||
@@ -335,6 +343,20 @@ type AgentEvent struct {
|
||||
Usage *PromptUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ModelSelectionWarning is the structured, provider-neutral explanation for
|
||||
// continuing with an executor model other than the saved profile request.
|
||||
type ModelSelectionWarning struct {
|
||||
Kind string `json:"kind"`
|
||||
DecisionID string `json:"decision_id,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
RequestedModel string `json:"requested_model,omitempty"`
|
||||
EffectiveModel string `json:"effective_model,omitempty"`
|
||||
FallbackModel string `json:"fallback_model,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
ExecutorType string `json:"executor_type,omitempty"`
|
||||
ExecutorProfileID string `json:"executor_profile_id,omitempty"`
|
||||
}
|
||||
|
||||
// PlanEntry represents an entry in the agent's execution plan.
|
||||
type PlanEntry struct {
|
||||
// Description is the content/description of the task.
|
||||
@@ -420,6 +442,15 @@ type SessionModelInfo struct {
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// SessionModelState is the synchronous model snapshot returned with a newly
|
||||
// created or loaded session. It lets lifecycle callers apply a saved model
|
||||
// before the asynchronous session_models stream event is dispatched.
|
||||
type SessionModelState struct {
|
||||
CurrentModelID string `json:"current_model_id,omitempty"`
|
||||
Models []SessionModelInfo `json:"models,omitempty"`
|
||||
ConfigOptions []ConfigOption `json:"config_options,omitempty"`
|
||||
}
|
||||
|
||||
// AuthMethodInfo represents an authentication method from ACP initialize.
|
||||
type AuthMethodInfo struct {
|
||||
// ID is the auth method identifier.
|
||||
|
||||
@@ -267,11 +267,12 @@ const (
|
||||
|
||||
// Event types for ACP capabilities and models
|
||||
const (
|
||||
AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received
|
||||
SessionModelsUpdated = "session_models.updated" // Session models received
|
||||
SessionModelFallbackUpdated = "session_model_fallback.updated" // Session started on the profile's fallback model
|
||||
SessionInfoUpdated = "session_info.updated" // ACP session info received
|
||||
SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed
|
||||
AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received
|
||||
SessionModelsUpdated = "session_models.updated" // Session models received
|
||||
SessionModelFallbackUpdated = "session_model_fallback.updated" // Session started on the profile's fallback model
|
||||
SessionModelSelectionWarningUpdated = "session_model_selection_warning.updated" // Executor-authoritative model decision warning
|
||||
SessionInfoUpdated = "session_info.updated" // ACP session info received
|
||||
SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed
|
||||
)
|
||||
|
||||
// Event types for session todos (ACP plan entries)
|
||||
@@ -486,6 +487,18 @@ func BuildSessionModelFallbackWildcardSubject() string {
|
||||
return SessionModelFallbackUpdated + ".*"
|
||||
}
|
||||
|
||||
// BuildSessionModelSelectionWarningSubject creates a session-specific model
|
||||
// selection warning subject.
|
||||
func BuildSessionModelSelectionWarningSubject(sessionID string) string {
|
||||
return SessionModelSelectionWarningUpdated + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildSessionModelSelectionWarningWildcardSubject creates a wildcard
|
||||
// subscription for model selection warnings.
|
||||
func BuildSessionModelSelectionWarningWildcardSubject() string {
|
||||
return SessionModelSelectionWarningUpdated + ".*"
|
||||
}
|
||||
|
||||
// BuildSessionMCPStatusSubject creates a session-specific MCP status subject.
|
||||
func BuildSessionMCPStatusSubject(sessionID string) string {
|
||||
return SessionMCPStatusUpdated + "." + sessionID
|
||||
|
||||
@@ -52,6 +52,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB
|
||||
b.subscribe(eventBus, events.BuildAgentCapabilitiesWildcardSubject(), ws.ActionSessionAgentCapabilities)
|
||||
b.subscribe(eventBus, events.BuildSessionModelsWildcardSubject(), ws.ActionSessionModelsUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionModelFallbackWildcardSubject(), ws.ActionSessionModelFallback)
|
||||
b.subscribe(eventBus, events.BuildSessionModelSelectionWarningWildcardSubject(), ws.ActionSessionModelSelectionWarning)
|
||||
b.subscribe(eventBus, events.BuildSessionMCPStatusWildcardSubject(), ws.ActionSessionMCPStatusUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionInfoWildcardSubject(), ws.ActionSessionInfoUpdated)
|
||||
b.subscribe(eventBus, events.BuildSessionTodosWildcardSubject(), ws.ActionSessionTodosUpdated)
|
||||
|
||||
@@ -143,6 +143,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
|
||||
case "session_model_fallback":
|
||||
s.handleSessionModelFallbackEvent(ctx, payload)
|
||||
|
||||
case streams.EventTypeSessionModelSelectionWarning:
|
||||
s.handleSessionModelSelectionWarningEvent(ctx, payload)
|
||||
|
||||
case streams.EventTypeMCPAttachment:
|
||||
s.handleSessionMCPAttachmentEvent(ctx, payload)
|
||||
|
||||
@@ -2936,6 +2939,177 @@ func (s *Service) handleSessionModelFallbackEvent(ctx context.Context, payload *
|
||||
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelFallbackUpdated, "orchestrator", eventPayload))
|
||||
}
|
||||
|
||||
// handleSessionModelSelectionWarningEvent persists one structured status
|
||||
// message for an executor-authoritative model decision and publishes the same
|
||||
// data to live WebSocket subscribers. Persistence is best-effort and never
|
||||
// blocks the task launch.
|
||||
func (s *Service) handleSessionModelSelectionWarningEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
warning, sessionID, ok := s.modelSelectionWarningEvent(ctx, payload)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var releaseClaim func()
|
||||
if s.messageCreator != nil {
|
||||
var claimed bool
|
||||
releaseClaim, claimed = s.claimModelSelectionWarning(ctx, sessionID, warning.DecisionID)
|
||||
if !claimed {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.persistModelSelectionWarningMessage(ctx, payload.TaskID, sessionID, warning); err != nil {
|
||||
releaseClaim()
|
||||
}
|
||||
s.publishModelSelectionWarning(ctx, payload.TaskID, sessionID, warning)
|
||||
}
|
||||
|
||||
func (s *Service) modelSelectionWarningEvent(
|
||||
ctx context.Context,
|
||||
payload *lifecycle.AgentStreamEventPayload,
|
||||
) (streams.ModelSelectionWarning, string, bool) {
|
||||
if payload == nil || payload.Data == nil || payload.Data.ModelSelectionWarning == nil {
|
||||
return streams.ModelSelectionWarning{}, "", false
|
||||
}
|
||||
sessionID := payload.SessionID
|
||||
if sessionID == "" && payload.TaskID != "" && s.repo != nil {
|
||||
if sess, err := s.repo.GetActiveTaskSessionByTaskID(ctx, payload.TaskID); err == nil && sess != nil {
|
||||
sessionID = sess.ID
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
return streams.ModelSelectionWarning{}, "", false
|
||||
}
|
||||
return *payload.Data.ModelSelectionWarning, sessionID, true
|
||||
}
|
||||
|
||||
func (s *Service) claimModelSelectionWarning(ctx context.Context, sessionID, decisionID string) (func(), bool) {
|
||||
if s.repo == nil || decisionID == "" {
|
||||
return func() {}, true
|
||||
}
|
||||
// A decision ID is created by lifecycle and is stable across event replay.
|
||||
// Use the structured metadata key as an atomic claim so two deliveries cannot
|
||||
// create duplicate status messages after a reconnect or restart.
|
||||
claimCtx := context.WithoutCancel(ctx)
|
||||
key := "model_selection_warning:" + decisionID
|
||||
if claimer, ok := s.repo.(failedSessionMetadataClaimer); ok {
|
||||
return s.claimModelSelectionWarningWithState(claimCtx, sessionID, key, claimer)
|
||||
}
|
||||
claimed, err := s.repo.SetSessionMetadataKeyIfAbsent(claimCtx, sessionID, key, true)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to claim model selection warning persistence",
|
||||
zap.String("session_id", sessionID), zap.Error(err))
|
||||
return func() {}, false
|
||||
}
|
||||
return func() {}, claimed
|
||||
}
|
||||
|
||||
func (s *Service) claimModelSelectionWarningWithState(
|
||||
ctx context.Context,
|
||||
sessionID, key string,
|
||||
claimer failedSessionMetadataClaimer,
|
||||
) (func(), bool) {
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil || session == nil {
|
||||
s.logger.Warn("failed to load session for model selection warning claim",
|
||||
zap.String("session_id", sessionID), zap.Error(err))
|
||||
return func() {}, false
|
||||
}
|
||||
claimed, err := claimer.SetSessionMetadataKeyIfAbsentIfState(ctx, sessionID, key, true, session.State)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to claim model selection warning persistence",
|
||||
zap.String("session_id", sessionID), zap.Error(err))
|
||||
return func() {}, false
|
||||
}
|
||||
if !claimed {
|
||||
return func() {}, false
|
||||
}
|
||||
return func() {
|
||||
s.releaseModelSelectionWarningClaim(ctx, sessionID, key, session.State)
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Service) releaseModelSelectionWarningClaim(
|
||||
ctx context.Context,
|
||||
sessionID, key string,
|
||||
expectedState models.TaskSessionState,
|
||||
) {
|
||||
releaser, ok := s.repo.(failedSessionMetadataClaimReleaser)
|
||||
if !ok {
|
||||
s.logger.Warn("session repository cannot release model selection warning claim",
|
||||
zap.String("session_id", sessionID))
|
||||
return
|
||||
}
|
||||
if _, err := releaser.RemoveSessionMetadataKeyIfState(ctx, sessionID, key, expectedState); err != nil {
|
||||
s.logger.Warn("failed to release model selection warning claim",
|
||||
zap.String("session_id", sessionID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func modelSelectionWarningMetadata(warning streams.ModelSelectionWarning) map[string]interface{} {
|
||||
metadata := map[string]interface{}{
|
||||
"variant": "warning",
|
||||
"kind": warning.Kind,
|
||||
"reason": warning.Reason,
|
||||
"requested_model": warning.RequestedModel,
|
||||
"effective_model": warning.EffectiveModel,
|
||||
"agent_id": warning.AgentID,
|
||||
"executor_type": warning.ExecutorType,
|
||||
"executor_profile_id": warning.ExecutorProfileID,
|
||||
"decision_id": warning.DecisionID,
|
||||
"remediation": []string{"executor_credentials", "copied_agent_configuration", "agent_version"},
|
||||
}
|
||||
if warning.FallbackModel != "" {
|
||||
metadata["fallback_model"] = warning.FallbackModel
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (s *Service) persistModelSelectionWarningMessage(
|
||||
ctx context.Context,
|
||||
taskID, sessionID string,
|
||||
warning streams.ModelSelectionWarning,
|
||||
) error {
|
||||
if s.messageCreator == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.messageCreator.CreateSessionMessage(
|
||||
ctx,
|
||||
taskID,
|
||||
"The executor could not use the saved model selection.",
|
||||
sessionID,
|
||||
string(v1.MessageTypeStatus),
|
||||
s.getActiveTurnID(sessionID),
|
||||
modelSelectionWarningMetadata(warning),
|
||||
false,
|
||||
); err != nil {
|
||||
s.logger.Warn("failed to persist model selection warning",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) publishModelSelectionWarning(
|
||||
ctx context.Context,
|
||||
taskID, sessionID string,
|
||||
warning streams.ModelSelectionWarning,
|
||||
) {
|
||||
if s.eventBus == nil {
|
||||
return
|
||||
}
|
||||
eventPayload := lifecycle.SessionModelSelectionWarningEventPayload{
|
||||
TaskID: taskID,
|
||||
SessionID: sessionID,
|
||||
AgentID: warning.AgentID,
|
||||
Warning: warning,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
subject := events.BuildSessionModelSelectionWarningSubject(sessionID)
|
||||
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(
|
||||
events.SessionModelSelectionWarningUpdated, "orchestrator", eventPayload,
|
||||
))
|
||||
}
|
||||
|
||||
func workflowSessionConfigFailures(raw any) []string {
|
||||
data, ok := raw.(map[string]any)
|
||||
if !ok || data == nil {
|
||||
|
||||
@@ -3455,6 +3455,87 @@ func TestHandleSessionModelFallbackEventResolvesSessionFromTask(t *testing.T) {
|
||||
require.Equal(t, "gpt-5", payload.FallbackModel)
|
||||
}
|
||||
|
||||
func TestHandleSessionModelSelectionWarningPersistsIdempotently(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
messages := &mockMessageCreator{}
|
||||
eb := &recordingEventBus{}
|
||||
svc := &Service{logger: testLogger(), repo: repo, eventBus: eb, messageCreator: messages}
|
||||
warning := &streams.ModelSelectionWarning{
|
||||
Kind: "model_selection_warning",
|
||||
DecisionID: "decision-1",
|
||||
Reason: "requested_not_advertised",
|
||||
RequestedModel: "host-only-model",
|
||||
EffectiveModel: "executor-default",
|
||||
AgentID: "codex-acp",
|
||||
ExecutorType: "ssh",
|
||||
ExecutorProfileID: "executor-profile-1",
|
||||
}
|
||||
payload := &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1",
|
||||
SessionID: "s1",
|
||||
AgentID: "codex-acp",
|
||||
Data: &lifecycle.AgentStreamEventData{
|
||||
Type: streams.EventTypeSessionModelSelectionWarning,
|
||||
ModelSelectionWarning: warning,
|
||||
},
|
||||
}
|
||||
|
||||
svc.handleAgentStreamEvent(ctx, payload)
|
||||
svc.handleAgentStreamEvent(ctx, payload)
|
||||
|
||||
require.Len(t, messages.sessionMessages, 1)
|
||||
require.Equal(t, "s1", messages.sessionMessages[0].sessionID)
|
||||
require.Equal(t, string(v1.MessageTypeStatus), messages.sessionMessages[0].messageType)
|
||||
require.Equal(t, "model_selection_warning", messages.sessionMessages[0].metadata["kind"])
|
||||
require.Equal(t, "host-only-model", messages.sessionMessages[0].metadata["requested_model"])
|
||||
require.Equal(t, "executor-default", messages.sessionMessages[0].metadata["effective_model"])
|
||||
require.Equal(t, []string{"executor_credentials", "copied_agent_configuration", "agent_version"}, messages.sessionMessages[0].metadata["remediation"])
|
||||
require.Len(t, eb.events, 1)
|
||||
require.Equal(t, events.BuildSessionModelSelectionWarningSubject("s1"), eb.events[0].subject)
|
||||
|
||||
updated, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Metadata["model_selection_warning:decision-1"])
|
||||
}
|
||||
|
||||
func TestHandleSessionModelSelectionWarningReleasesClaimAfterPersistenceFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
messages := &mockMessageCreator{sessionMessageErr: errors.New("transient message failure")}
|
||||
svc := &Service{logger: testLogger(), repo: repo, messageCreator: messages}
|
||||
warning := &streams.ModelSelectionWarning{
|
||||
Kind: "model_selection_warning",
|
||||
DecisionID: "decision-retry",
|
||||
Reason: "requested_not_advertised",
|
||||
RequestedModel: "host-only-model",
|
||||
EffectiveModel: "executor-default",
|
||||
}
|
||||
payload := &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1",
|
||||
SessionID: "s1",
|
||||
Data: &lifecycle.AgentStreamEventData{
|
||||
Type: streams.EventTypeSessionModelSelectionWarning,
|
||||
ModelSelectionWarning: warning,
|
||||
},
|
||||
}
|
||||
|
||||
svc.handleAgentStreamEvent(ctx, payload)
|
||||
failed, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, failed.Metadata, "model_selection_warning:decision-retry")
|
||||
require.Empty(t, messages.sessionMessages)
|
||||
|
||||
messages.sessionMessageErr = nil
|
||||
svc.handleAgentStreamEvent(ctx, payload)
|
||||
require.Len(t, messages.sessionMessages, 1)
|
||||
completed, err := repo.GetTaskSession(ctx, "s1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, completed.Metadata["model_selection_warning:decision-retry"])
|
||||
}
|
||||
|
||||
func TestHandleSessionModelsEventStoresBaselineCandidateAndPublishesLiveState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
|
||||
@@ -221,8 +221,9 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec
|
||||
// Hoisted to constants so the table below doesn't duplicate string
|
||||
// literals that exist elsewhere in the package.
|
||||
const (
|
||||
profileKeyRemoteCredentials = "remote_credentials"
|
||||
profileKeyRemoteAuthSecrets = "remote_auth_secrets"
|
||||
profileKeyRemoteCredentials = "remote_credentials"
|
||||
profileKeyRemoteAuthSecrets = "remote_auth_secrets"
|
||||
profileKeyAgentConfigBundles = "agent_config_bundles"
|
||||
)
|
||||
|
||||
// profileConfigPassthroughKeys are profile.Config keys copied verbatim
|
||||
@@ -232,6 +233,7 @@ var profileConfigPassthroughKeys = []string{
|
||||
"sprites_network_policy_rules",
|
||||
profileKeyRemoteCredentials,
|
||||
profileKeyRemoteAuthSecrets,
|
||||
profileKeyAgentConfigBundles,
|
||||
"remote_auth_target_home",
|
||||
"git_user_name",
|
||||
"git_user_email",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/remoteauth"
|
||||
"github.com/kandev/kandev/internal/agent/remoteconfig"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/subproc"
|
||||
@@ -45,6 +46,7 @@ func (h *ExecutorProfileHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.GET("/executor-profiles", h.httpListAllProfiles)
|
||||
api.GET("/executor-profiles/default-script", h.httpGetDefaultScript)
|
||||
api.GET("/remote-credentials", h.httpListRemoteCredentials)
|
||||
api.GET("/agent-config-bundles", h.httpListAgentConfigBundles)
|
||||
api.GET("/git/identity", h.httpGetGitIdentity)
|
||||
api.GET("/script-placeholders", h.httpListScriptPlaceholders)
|
||||
api.GET("/executors/:id/profiles", h.httpListProfiles)
|
||||
@@ -118,6 +120,10 @@ func (h *ExecutorProfileHandlers) httpListRemoteCredentials(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ExecutorProfileHandlers) httpListAgentConfigBundles(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"bundles": h.buildAgentConfigBundles()})
|
||||
}
|
||||
|
||||
func (h *ExecutorProfileHandlers) httpGetGitIdentity(c *gin.Context) {
|
||||
name := firstNonEmptyGitConfig("user.name")
|
||||
email := firstNonEmptyGitConfig("user.email")
|
||||
@@ -153,6 +159,13 @@ func (h *ExecutorProfileHandlers) buildRemoteAuthSpecs() []remoteauth.Spec {
|
||||
return remoteauth.BuildCatalog(h.agentList.ListEnabled()).Specs
|
||||
}
|
||||
|
||||
func (h *ExecutorProfileHandlers) buildAgentConfigBundles() []remoteconfig.Bundle {
|
||||
if h.agentList == nil {
|
||||
return remoteconfig.BuildCatalog(nil).Bundles
|
||||
}
|
||||
return remoteconfig.BuildCatalog(h.agentList.ListEnabled()).Bundles
|
||||
}
|
||||
|
||||
func (h *ExecutorProfileHandlers) httpListProfiles(c *gin.Context) {
|
||||
executorID := c.Param("id")
|
||||
profiles, err := h.service.ListExecutorProfiles(c.Request.Context(), executorID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,6 +29,12 @@ func (l *recordingAgentLister) ListEnabled() []agents.Agent {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedAgentLister struct {
|
||||
agents []agents.Agent
|
||||
}
|
||||
|
||||
func (l fixedAgentLister) ListEnabled() []agents.Agent { return l.agents }
|
||||
|
||||
func newProfileHandlers(t *testing.T, repo *executorRepo, lister AgentLister) *ExecutorProfileHandlers {
|
||||
t.Helper()
|
||||
return NewExecutorProfileHandlers(newExecutorService(t, repo), lister, newTestLogger(t))
|
||||
@@ -156,6 +163,37 @@ func TestHTTPListRemoteCredentialsFallsBackWithoutAnAgentLister(t *testing.T) {
|
||||
require.Contains(t, rec.Body.String(), `"auth_specs"`)
|
||||
}
|
||||
|
||||
func TestHTTPListAgentConfigBundlesReturnsMetadataOnly(t *testing.T) {
|
||||
h := newProfileHandlers(t, executorFixture(), fixedAgentLister{
|
||||
agents: []agents.Agent{agents.NewClaudeACP(), agents.NewCodexACP(), agents.NewOpenCodeACP()},
|
||||
})
|
||||
c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/agent-config-bundles", "")
|
||||
|
||||
h.httpListAgentConfigBundles(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var body struct {
|
||||
Bundles []struct {
|
||||
ID string `json:"id"`
|
||||
Files []struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
TargetPath string `json:"target_path"`
|
||||
} `json:"files"`
|
||||
} `json:"bundles"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Len(t, body.Bundles, 3)
|
||||
for _, bundle := range body.Bundles {
|
||||
require.NotEmpty(t, bundle.ID)
|
||||
for _, file := range bundle.Files {
|
||||
require.NotEmpty(t, file.SourcePath)
|
||||
require.NotEmpty(t, file.TargetPath)
|
||||
require.NotContains(t, file.SourcePath, string(filepath.Separator)+"root"+string(filepath.Separator))
|
||||
}
|
||||
}
|
||||
require.NotContains(t, rec.Body.String(), "host-only-secret")
|
||||
}
|
||||
|
||||
// TestHTTPGetGitIdentityReportsDetectionConsistently shells out to the real
|
||||
// git, so the values depend on the host. The invariant that must hold either
|
||||
// way is that "detected" agrees with the two fields it summarizes.
|
||||
|
||||
@@ -99,6 +99,7 @@ func TestRegisterExecutorProfileRoutesWiresBothDeleteRoutes(t *testing.T) {
|
||||
"GET /api/v1/executor-profiles",
|
||||
"GET /api/v1/executor-profiles/default-script",
|
||||
"GET /api/v1/remote-credentials",
|
||||
"GET /api/v1/agent-config-bundles",
|
||||
"GET /api/v1/git/identity",
|
||||
"GET /api/v1/script-placeholders",
|
||||
"GET /api/v1/executors/:id/profiles",
|
||||
|
||||
@@ -179,75 +179,76 @@ const (
|
||||
|
||||
// Native code review. The *.run / *.cancel / *.get / *.finding.update /
|
||||
// *.clear actions are client requests; the rest are server notifications.
|
||||
ActionTaskReviewRun = "task.review.run"
|
||||
ActionTaskReviewCancel = "task.review.cancel"
|
||||
ActionTaskReviewGet = "task.review.get"
|
||||
ActionTaskReviewFindingUpdate = "task.review.finding.update"
|
||||
ActionTaskReviewClear = "task.review.clear"
|
||||
ActionTaskReviewRunUpdated = "task.review.run_updated"
|
||||
ActionTaskReviewFindingsPublished = "task.review.findings_published"
|
||||
ActionTaskReviewFindingUpdated = "task.review.finding_updated"
|
||||
ActionTaskReviewCleared = "task.review.cleared"
|
||||
ActionAgentUpdated = "agent.updated"
|
||||
ActionAgentAvailableUpdated = "agent.available.updated"
|
||||
ActionAgentInstallStarted = "agent.install.started"
|
||||
ActionAgentInstallOutput = "agent.install.output"
|
||||
ActionAgentInstallFinished = "agent.install.finished"
|
||||
ActionAgentUpdateStarted = "agent.update.started"
|
||||
ActionAgentUpdateOutput = "agent.update.output"
|
||||
ActionAgentUpdateFinished = "agent.update.finished"
|
||||
ActionWorkspaceCreated = "workspace.created"
|
||||
ActionWorkspaceUpdated = "workspace.updated"
|
||||
ActionWorkspaceDeleted = "workspace.deleted"
|
||||
ActionWorkflowCreated = "workflow.created"
|
||||
ActionWorkflowUpdated = "workflow.updated"
|
||||
ActionWorkflowDeleted = "workflow.deleted"
|
||||
ActionWorkflowStepCreated = "workflow.step.created"
|
||||
ActionWorkflowStepUpdated = "workflow.step.updated"
|
||||
ActionWorkflowStepDeleted = "workflow.step.deleted"
|
||||
ActionSessionMessageAdded = "session.message.added"
|
||||
ActionSessionMessageUpdated = "session.message.updated"
|
||||
ActionSessionMessageDeleted = "session.message.deleted"
|
||||
ActionSessionStateChanged = "session.state_changed"
|
||||
ActionSessionActivityChanged = "session.activity_changed"
|
||||
ActionSessionCancellationChanged = "session.cancellation_changed"
|
||||
ActionTaskStatusSummaryUpdated = "task.status_summary.updated"
|
||||
ActionSessionAgentctlStarting = "session.agentctl_starting"
|
||||
ActionSessionAgentctlReady = "session.agentctl_ready"
|
||||
ActionSessionAgentctlError = "session.agentctl_error"
|
||||
ActionSessionTurnStarted = "session.turn.started"
|
||||
ActionSessionTurnCompleted = "session.turn.completed"
|
||||
ActionSessionAvailableCommands = "session.available_commands"
|
||||
ActionSessionModeChanged = "session.mode_changed"
|
||||
ActionSessionAgentCapabilities = "session.agent_capabilities"
|
||||
ActionSessionModelsUpdated = "session.models_updated"
|
||||
ActionSessionModelFallback = "session.model_fallback"
|
||||
ActionSessionMCPStatusUpdated = "session.mcp_status_updated"
|
||||
ActionSessionInfoUpdated = "session.info_updated"
|
||||
ActionSessionSetMode = "session.set_mode"
|
||||
ActionSessionTodosUpdated = "session.todos_updated"
|
||||
ActionSessionPromptUsage = "session.prompt_usage"
|
||||
ActionSessionPollModeChanged = "session.poll_mode_changed"
|
||||
ActionInputRequested = "input.requested"
|
||||
ActionRepositoryCreated = "repository.created"
|
||||
ActionRepositoryUpdated = "repository.updated"
|
||||
ActionRepositoryDeleted = "repository.deleted"
|
||||
ActionRepositoryScriptCreated = "repository.script.created"
|
||||
ActionRepositoryScriptUpdated = "repository.script.updated"
|
||||
ActionRepositoryScriptDeleted = "repository.script.deleted"
|
||||
ActionExecutorCreated = "executor.created"
|
||||
ActionExecutorUpdated = "executor.updated"
|
||||
ActionExecutorDeleted = "executor.deleted"
|
||||
ActionEnvironmentCreated = "environment.created"
|
||||
ActionEnvironmentUpdated = "environment.updated"
|
||||
ActionEnvironmentDeleted = "environment.deleted"
|
||||
ActionExecutorProfileCreated = "executor.profile.created"
|
||||
ActionExecutorProfileUpdated = "executor.profile.updated"
|
||||
ActionExecutorProfileDeleted = "executor.profile.deleted"
|
||||
ActionExecutorPrepareProgress = "executor.prepare.progress"
|
||||
ActionExecutorPrepareCompleted = "executor.prepare.completed"
|
||||
ActionSystemMetricsUpdated = "system.metrics.updated"
|
||||
ActionUpdateAvailable = "system.update_available"
|
||||
ActionTaskReviewRun = "task.review.run"
|
||||
ActionTaskReviewCancel = "task.review.cancel"
|
||||
ActionTaskReviewGet = "task.review.get"
|
||||
ActionTaskReviewFindingUpdate = "task.review.finding.update"
|
||||
ActionTaskReviewClear = "task.review.clear"
|
||||
ActionTaskReviewRunUpdated = "task.review.run_updated"
|
||||
ActionTaskReviewFindingsPublished = "task.review.findings_published"
|
||||
ActionTaskReviewFindingUpdated = "task.review.finding_updated"
|
||||
ActionTaskReviewCleared = "task.review.cleared"
|
||||
ActionAgentUpdated = "agent.updated"
|
||||
ActionAgentAvailableUpdated = "agent.available.updated"
|
||||
ActionAgentInstallStarted = "agent.install.started"
|
||||
ActionAgentInstallOutput = "agent.install.output"
|
||||
ActionAgentInstallFinished = "agent.install.finished"
|
||||
ActionAgentUpdateStarted = "agent.update.started"
|
||||
ActionAgentUpdateOutput = "agent.update.output"
|
||||
ActionAgentUpdateFinished = "agent.update.finished"
|
||||
ActionWorkspaceCreated = "workspace.created"
|
||||
ActionWorkspaceUpdated = "workspace.updated"
|
||||
ActionWorkspaceDeleted = "workspace.deleted"
|
||||
ActionWorkflowCreated = "workflow.created"
|
||||
ActionWorkflowUpdated = "workflow.updated"
|
||||
ActionWorkflowDeleted = "workflow.deleted"
|
||||
ActionWorkflowStepCreated = "workflow.step.created"
|
||||
ActionWorkflowStepUpdated = "workflow.step.updated"
|
||||
ActionWorkflowStepDeleted = "workflow.step.deleted"
|
||||
ActionSessionMessageAdded = "session.message.added"
|
||||
ActionSessionMessageUpdated = "session.message.updated"
|
||||
ActionSessionMessageDeleted = "session.message.deleted"
|
||||
ActionSessionStateChanged = "session.state_changed"
|
||||
ActionSessionActivityChanged = "session.activity_changed"
|
||||
ActionSessionCancellationChanged = "session.cancellation_changed"
|
||||
ActionTaskStatusSummaryUpdated = "task.status_summary.updated"
|
||||
ActionSessionAgentctlStarting = "session.agentctl_starting"
|
||||
ActionSessionAgentctlReady = "session.agentctl_ready"
|
||||
ActionSessionAgentctlError = "session.agentctl_error"
|
||||
ActionSessionTurnStarted = "session.turn.started"
|
||||
ActionSessionTurnCompleted = "session.turn.completed"
|
||||
ActionSessionAvailableCommands = "session.available_commands"
|
||||
ActionSessionModeChanged = "session.mode_changed"
|
||||
ActionSessionAgentCapabilities = "session.agent_capabilities"
|
||||
ActionSessionModelsUpdated = "session.models_updated"
|
||||
ActionSessionModelFallback = "session.model_fallback"
|
||||
ActionSessionModelSelectionWarning = "session.model_selection_warning"
|
||||
ActionSessionMCPStatusUpdated = "session.mcp_status_updated"
|
||||
ActionSessionInfoUpdated = "session.info_updated"
|
||||
ActionSessionSetMode = "session.set_mode"
|
||||
ActionSessionTodosUpdated = "session.todos_updated"
|
||||
ActionSessionPromptUsage = "session.prompt_usage"
|
||||
ActionSessionPollModeChanged = "session.poll_mode_changed"
|
||||
ActionInputRequested = "input.requested"
|
||||
ActionRepositoryCreated = "repository.created"
|
||||
ActionRepositoryUpdated = "repository.updated"
|
||||
ActionRepositoryDeleted = "repository.deleted"
|
||||
ActionRepositoryScriptCreated = "repository.script.created"
|
||||
ActionRepositoryScriptUpdated = "repository.script.updated"
|
||||
ActionRepositoryScriptDeleted = "repository.script.deleted"
|
||||
ActionExecutorCreated = "executor.created"
|
||||
ActionExecutorUpdated = "executor.updated"
|
||||
ActionExecutorDeleted = "executor.deleted"
|
||||
ActionEnvironmentCreated = "environment.created"
|
||||
ActionEnvironmentUpdated = "environment.updated"
|
||||
ActionEnvironmentDeleted = "environment.deleted"
|
||||
ActionExecutorProfileCreated = "executor.profile.created"
|
||||
ActionExecutorProfileUpdated = "executor.profile.updated"
|
||||
ActionExecutorProfileDeleted = "executor.profile.deleted"
|
||||
ActionExecutorPrepareProgress = "executor.prepare.progress"
|
||||
ActionExecutorPrepareCompleted = "executor.prepare.completed"
|
||||
ActionSystemMetricsUpdated = "system.metrics.updated"
|
||||
ActionUpdateAvailable = "system.update_available"
|
||||
|
||||
ActionAgentProfileDeleted = "agent.profile.deleted"
|
||||
ActionAgentProfileCreated = "agent.profile.created"
|
||||
|
||||
@@ -54,6 +54,7 @@ import { serializeSettingsRevision } from "@/components/settings/settings-save-r
|
||||
import {
|
||||
deriveSpritesSecretId,
|
||||
getGitIdentityBaseline,
|
||||
parseAgentConfigBundles,
|
||||
parseNetworkPolicyRules,
|
||||
parseRemoteAuthSecrets,
|
||||
parseRemoteCredentials,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
import type { Executor, ExecutorProfile, ExecutorType, ProfileEnvVar } from "@/lib/types/http";
|
||||
import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api";
|
||||
import { executorProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
|
||||
import { buildSaveConfig } from "@/components/settings/profile-edit/serialize-executor-config";
|
||||
|
||||
const EXECUTORS_ROUTE = "/settings/executors";
|
||||
const SPRITES_TOKEN_KEY = "SPRITES_API_TOKEN";
|
||||
@@ -98,6 +100,9 @@ function useRemoteAuthState(profile: ExecutorProfile) {
|
||||
const [remoteCredentials, setRemoteCredentials] = useState<string[]>(() =>
|
||||
parseRemoteCredentials(profile.config),
|
||||
);
|
||||
const [configBundleIds, setConfigBundleIds] = useState<string[]>(() =>
|
||||
parseAgentConfigBundles(profile.config),
|
||||
);
|
||||
const [agentEnvVars, setAgentEnvVars] = useState<Record<string, string | null>>(() =>
|
||||
parseRemoteAuthSecrets(profile.config),
|
||||
);
|
||||
@@ -111,6 +116,8 @@ function useRemoteAuthState(profile: ExecutorProfile) {
|
||||
setNetworkPolicyRules,
|
||||
remoteCredentials,
|
||||
setRemoteCredentials,
|
||||
configBundleIds,
|
||||
setConfigBundleIds,
|
||||
agentEnvVars,
|
||||
handleAgentEnvVarChange,
|
||||
};
|
||||
@@ -327,6 +334,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
setNetworkPolicyRules: remoteAuth.setNetworkPolicyRules,
|
||||
remoteCredentials: remoteAuth.remoteCredentials,
|
||||
setRemoteCredentials: remoteAuth.setRemoteCredentials,
|
||||
configBundleIds: remoteAuth.configBundleIds,
|
||||
setConfigBundleIds: remoteAuth.setConfigBundleIds,
|
||||
agentEnvVars: remoteAuth.agentEnvVars,
|
||||
handleAgentEnvVarChange: remoteAuth.handleAgentEnvVarChange,
|
||||
localGitIdentity: gitIdentity.localGitIdentity,
|
||||
@@ -347,70 +356,6 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSaveConfig(
|
||||
form: ReturnType<typeof useProfileFormState>,
|
||||
baseConfig?: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const config: Record<string, string> = { ...baseConfig };
|
||||
if (form.isSprites && form.networkPolicyRules.length > 0) {
|
||||
config.sprites_network_policy_rules = JSON.stringify(form.networkPolicyRules);
|
||||
} else {
|
||||
delete config.sprites_network_policy_rules;
|
||||
}
|
||||
if (form.isRemote && form.remoteCredentials.length > 0) {
|
||||
config.remote_credentials = JSON.stringify(form.remoteCredentials);
|
||||
} else {
|
||||
delete config.remote_credentials;
|
||||
}
|
||||
const nonNullEnvVars = Object.fromEntries(
|
||||
Object.entries(form.agentEnvVars).filter(([, v]) => v != null),
|
||||
);
|
||||
if (form.isRemote && Object.keys(nonNullEnvVars).length > 0) {
|
||||
config.remote_auth_secrets = JSON.stringify(nonNullEnvVars);
|
||||
} else {
|
||||
delete config.remote_auth_secrets;
|
||||
}
|
||||
const effectiveName =
|
||||
form.gitIdentityMode === "local"
|
||||
? form.localGitIdentity.userName.trim()
|
||||
: form.gitUserName.trim();
|
||||
const effectiveEmail =
|
||||
form.gitIdentityMode === "local"
|
||||
? form.localGitIdentity.userEmail.trim()
|
||||
: form.gitUserEmail.trim();
|
||||
if (form.isRemote && effectiveName) {
|
||||
config.git_user_name = effectiveName;
|
||||
} else {
|
||||
delete config.git_user_name;
|
||||
}
|
||||
if (form.isRemote && effectiveEmail) {
|
||||
config.git_user_email = effectiveEmail;
|
||||
} else {
|
||||
delete config.git_user_email;
|
||||
}
|
||||
applyDockerConfig(config, form);
|
||||
if (form.isSSH && form.sshShell.trim()) config.ssh_shell = form.sshShell.trim();
|
||||
else delete config.ssh_shell;
|
||||
return config;
|
||||
}
|
||||
|
||||
function applyDockerConfig(
|
||||
config: Record<string, string>,
|
||||
form: ReturnType<typeof useProfileFormState>,
|
||||
): void {
|
||||
if (!form.isDocker) return;
|
||||
if (form.dockerfile.trim()) {
|
||||
config.dockerfile = form.dockerfile;
|
||||
} else {
|
||||
delete config.dockerfile;
|
||||
}
|
||||
if (form.imageTag.trim()) {
|
||||
config.image_tag = form.imageTag.trim();
|
||||
} else {
|
||||
delete config.image_tag;
|
||||
}
|
||||
}
|
||||
|
||||
type ProfileEditSectionsProps = {
|
||||
executor: Executor;
|
||||
profile: ExecutorProfile;
|
||||
@@ -458,6 +403,10 @@ function ExecutorSpecificSections({ executor, profile, form, secrets }: ProfileE
|
||||
remoteCredentials={form.remoteCredentials}
|
||||
baselineRemoteCredentials={parseRemoteCredentials(profile.config)}
|
||||
onRemoteCredentialsChange={form.setRemoteCredentials}
|
||||
configBundleIds={form.configBundleIds}
|
||||
baselineConfigBundleIds={parseAgentConfigBundles(profile.config)}
|
||||
onConfigBundleChange={form.setConfigBundleIds}
|
||||
isSSH={form.isSSH}
|
||||
agentEnvVars={form.agentEnvVars}
|
||||
baselineAgentEnvVars={parseRemoteAuthSecrets(profile.config)}
|
||||
onAgentEnvVarChange={form.handleAgentEnvVarChange}
|
||||
|
||||
@@ -125,6 +125,7 @@ type BuildProfileConfigInput = {
|
||||
isDocker: boolean;
|
||||
networkPolicyRules: NetworkPolicyRule[];
|
||||
remoteCredentials: string[];
|
||||
configBundleIds: string[];
|
||||
agentEnvVars: Record<string, string | null>;
|
||||
gitIdentityMode: GitIdentityMode;
|
||||
localGitIdentity: GitIdentityState;
|
||||
@@ -141,6 +142,7 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
|
||||
isDocker,
|
||||
networkPolicyRules,
|
||||
remoteCredentials,
|
||||
configBundleIds,
|
||||
agentEnvVars,
|
||||
gitIdentityMode,
|
||||
localGitIdentity,
|
||||
@@ -156,6 +158,9 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
|
||||
if (isRemote && remoteCredentials.length > 0) {
|
||||
config.remote_credentials = JSON.stringify(remoteCredentials);
|
||||
}
|
||||
if (isRemote && configBundleIds.length > 0) {
|
||||
config.agent_config_bundles = JSON.stringify(configBundleIds);
|
||||
}
|
||||
const nonNullEnvVars = Object.fromEntries(
|
||||
Object.entries(agentEnvVars).filter(([, v]) => v != null),
|
||||
);
|
||||
@@ -217,6 +222,7 @@ function useCreateRemoteFlags(executorType: ExecutorType) {
|
||||
|
||||
function useCreateRemoteAuthState() {
|
||||
const [remoteCredentials, setRemoteCredentials] = useState<string[]>([]);
|
||||
const [configBundleIds, setConfigBundleIds] = useState<string[]>([]);
|
||||
const [agentEnvVars, setAgentEnvVars] = useState<Record<string, string | null>>({});
|
||||
const [networkPolicyRules, setNetworkPolicyRules] = useState<NetworkPolicyRule[]>([]);
|
||||
|
||||
@@ -227,6 +233,8 @@ function useCreateRemoteAuthState() {
|
||||
return {
|
||||
remoteCredentials,
|
||||
setRemoteCredentials,
|
||||
configBundleIds,
|
||||
setConfigBundleIds,
|
||||
agentEnvVars,
|
||||
handleAgentEnvVarChange,
|
||||
networkPolicyRules,
|
||||
@@ -344,6 +352,8 @@ function useCreateProfileFormState(executorType: ExecutorType) {
|
||||
setNetworkPolicyRules: remoteAuth.setNetworkPolicyRules,
|
||||
remoteCredentials: remoteAuth.remoteCredentials,
|
||||
setRemoteCredentials: remoteAuth.setRemoteCredentials,
|
||||
configBundleIds: remoteAuth.configBundleIds,
|
||||
setConfigBundleIds: remoteAuth.setConfigBundleIds,
|
||||
agentEnvVars: remoteAuth.agentEnvVars,
|
||||
handleAgentEnvVarChange: remoteAuth.handleAgentEnvVarChange,
|
||||
localGitIdentity: gitIdentity.localGitIdentity,
|
||||
@@ -433,28 +443,7 @@ function CreateProfileSections({
|
||||
onBuildSuccess={form.recordDockerBuildSuccess}
|
||||
/>
|
||||
)}
|
||||
{form.isRemote && (
|
||||
<RemoteCredentialsCard
|
||||
isRemote={form.isRemote}
|
||||
selectedIds={form.remoteCredentials}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={form.setRemoteCredentials}
|
||||
agentEnvVars={form.agentEnvVars}
|
||||
baselineAgentEnvVars={{}}
|
||||
onAgentEnvVarChange={form.handleAgentEnvVarChange}
|
||||
secrets={secrets}
|
||||
gitIdentityMode={form.gitIdentityMode}
|
||||
baselineGitIdentityMode="override"
|
||||
onGitIdentityModeChange={form.setGitIdentityMode}
|
||||
gitUserName={form.gitUserName}
|
||||
gitUserEmail={form.gitUserEmail}
|
||||
baselineGitUserName=""
|
||||
baselineGitUserEmail=""
|
||||
onGitUserNameChange={form.setGitUserName}
|
||||
onGitUserEmailChange={form.setGitUserEmail}
|
||||
localGitIdentity={form.localGitIdentity}
|
||||
/>
|
||||
)}
|
||||
<CreateRemoteCredentialsSection executorType={executorType} form={form} secrets={secrets} />
|
||||
{form.isSprites && (
|
||||
<NetworkPoliciesCard
|
||||
rules={form.networkPolicyRules}
|
||||
@@ -502,6 +491,43 @@ function CreateProfileSections({
|
||||
);
|
||||
}
|
||||
|
||||
function CreateRemoteCredentialsSection({
|
||||
executorType,
|
||||
form,
|
||||
secrets,
|
||||
}: {
|
||||
executorType: ExecutorType;
|
||||
form: ReturnType<typeof useCreateProfileFormState>;
|
||||
secrets: ReturnType<typeof useSecrets>["items"];
|
||||
}) {
|
||||
if (!form.isRemote) return null;
|
||||
return (
|
||||
<RemoteCredentialsCard
|
||||
isRemote
|
||||
selectedIds={form.remoteCredentials}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={form.setRemoteCredentials}
|
||||
configBundleIds={form.configBundleIds}
|
||||
onConfigBundleChange={form.setConfigBundleIds}
|
||||
isSSH={executorType === "ssh"}
|
||||
agentEnvVars={form.agentEnvVars}
|
||||
baselineAgentEnvVars={{}}
|
||||
onAgentEnvVarChange={form.handleAgentEnvVarChange}
|
||||
secrets={secrets}
|
||||
gitIdentityMode={form.gitIdentityMode}
|
||||
baselineGitIdentityMode="override"
|
||||
onGitIdentityModeChange={form.setGitIdentityMode}
|
||||
gitUserName={form.gitUserName}
|
||||
gitUserEmail={form.gitUserEmail}
|
||||
baselineGitUserName=""
|
||||
baselineGitUserEmail=""
|
||||
onGitUserNameChange={form.setGitUserName}
|
||||
onGitUserEmailChange={form.setGitUserEmail}
|
||||
localGitIdentity={form.localGitIdentity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Returns a catalog key (or null when the form is submittable) so the reason
|
||||
// resolves at render rather than at module load.
|
||||
function getCreateDisabledReasonKey(
|
||||
@@ -531,6 +557,7 @@ function buildCreateProfilePayload(form: ReturnType<typeof useCreateProfileFormS
|
||||
isDocker: form.isDocker,
|
||||
networkPolicyRules: form.networkPolicyRules,
|
||||
remoteCredentials: form.remoteCredentials,
|
||||
configBundleIds: form.configBundleIds,
|
||||
agentEnvVars: form.agentEnvVars,
|
||||
gitIdentityMode: form.gitIdentityMode,
|
||||
localGitIdentity: form.localGitIdentity,
|
||||
|
||||
@@ -17,6 +17,10 @@ export function parseRemoteCredentials(config?: Record<string, string>): string[
|
||||
return parseJsonSetting(config?.remote_credentials, []);
|
||||
}
|
||||
|
||||
export function parseAgentConfigBundles(config?: Record<string, string>): string[] {
|
||||
return parseJsonSetting(config?.agent_config_bundles, []);
|
||||
}
|
||||
|
||||
export function parseRemoteAuthSecrets(
|
||||
config?: Record<string, string>,
|
||||
): Record<string, string | null> {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import type { AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||
|
||||
type AgentConfigOptionsProps = {
|
||||
agentId: string;
|
||||
bundles: AgentConfigBundle[];
|
||||
selectedIds: string[];
|
||||
baselineSelectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
isSSH?: boolean;
|
||||
};
|
||||
|
||||
const PORTABLE_CONFIG_LABEL_KEYS: Record<string, string> = {
|
||||
"claude.settings": "executors:portableConfigBundleClaudeSettings",
|
||||
"codex.config": "executors:portableConfigBundleCodexConfig",
|
||||
"opencode.config": "executors:portableConfigBundleOpenCodeConfig",
|
||||
"mock.settings": "executors:portableConfigBundleMockSettings",
|
||||
};
|
||||
|
||||
export function AgentConfigOptions({
|
||||
agentId,
|
||||
bundles,
|
||||
selectedIds,
|
||||
baselineSelectedIds,
|
||||
onChange,
|
||||
isSSH = false,
|
||||
}: AgentConfigOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
if (bundles.length === 0) return null;
|
||||
|
||||
const selected = new Set(selectedIds);
|
||||
const baseline = new Set(baselineSelectedIds);
|
||||
const bundleIds = new Set(bundles.map((bundle) => bundle.id));
|
||||
const isDirty = [...bundleIds].some(
|
||||
(bundleId) => selected.has(bundleId) !== baseline.has(bundleId),
|
||||
);
|
||||
|
||||
const toggle = (bundleId: string, checked: boolean) => {
|
||||
const next = new Set(selected);
|
||||
if (checked) next.add(bundleId);
|
||||
else next.delete(bundleId);
|
||||
onChange([...next]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("executors:portableConfigTitle")}
|
||||
className="grid gap-2"
|
||||
data-testid={`agent-config-options-${agentId}`}
|
||||
data-agent-id={agentId}
|
||||
data-settings-dirty={isDirty}
|
||||
>
|
||||
{bundles.map((bundle, index) => {
|
||||
const labelKey = PORTABLE_CONFIG_LABEL_KEYS[bundle.id];
|
||||
const label = labelKey
|
||||
? t(labelKey)
|
||||
: t("executors:portableConfigBundleGeneric", { id: bundle.id });
|
||||
const bundleIsDirty = selected.has(bundle.id) !== baseline.has(bundle.id);
|
||||
return (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="flex min-h-11 items-start gap-2 rounded-md border border-border p-3 transition-colors hover:bg-muted/40"
|
||||
data-testid={`portable-config-bundle-${bundle.id}`}
|
||||
data-settings-dirty={bundleIsDirty}
|
||||
>
|
||||
<label className="flex min-w-0 flex-1 cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selected.has(bundle.id)}
|
||||
disabled={!bundle.available && !selected.has(bundle.id)}
|
||||
onCheckedChange={(checked) => toggle(bundle.id, checked === true)}
|
||||
aria-label={label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<span>{label}</span>
|
||||
<Badge variant={bundle.available ? "secondary" : "outline"}>
|
||||
{bundle.available
|
||||
? t("executors:portableConfigAvailable")
|
||||
: t("executors:portableConfigNotFound")}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="mt-1 block space-y-0.5 text-xs text-muted-foreground">
|
||||
{bundle.files.map((file) => (
|
||||
<span key={`${bundle.id}:${file.target_path}`} className="block break-all">
|
||||
<Trans
|
||||
i18nKey="executors:portableConfigFileMapping"
|
||||
values={{ source: file.source_path, target: file.target_path }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{!bundle.available && (
|
||||
<span className="mt-1 block text-xs text-amber-600 dark:text-amber-400">
|
||||
{t("executors:portableConfigNotFoundHint")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{index === 0 && (
|
||||
<PortableConfigInfo isSSH={isSSH} testId={`agent-config-info-${agentId}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortableConfigInfo({ isSSH, testId }: { isSSH: boolean; testId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const [open, setOpen] = useState(false);
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("executors:portableConfigInfoLabel")}
|
||||
aria-haspopup={usesTouchDrawer ? "dialog" : undefined}
|
||||
aria-expanded={usesTouchDrawer ? open : undefined}
|
||||
data-testid={testId}
|
||||
className={usesTouchDrawer ? "h-11 w-11 shrink-0" : "h-7 w-7 shrink-0"}
|
||||
>
|
||||
<IconAlertTriangle className="size-4 text-amber-500" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<div className="space-y-2 text-xs">
|
||||
<p>{t("executors:portableConfigInfoCopyTiming")}</p>
|
||||
<p>{t("executors:portableConfigInfoErrors")}</p>
|
||||
<p>{t("executors:portableConfigInfoScope")}</p>
|
||||
{isSSH && <p>{t("executors:portableConfigInfoSsh")}</p>}
|
||||
</div>
|
||||
);
|
||||
const trigger = usesTouchDrawer ? (
|
||||
<DrawerTrigger asChild>{button}</DrawerTrigger>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="max-w-[min(24rem,calc(100vw-2rem))]"
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
{trigger}
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center gap-2">
|
||||
<IconInfoCircle className="size-4" aria-hidden="true" />
|
||||
{t("executors:portableConfigInfoTitle")}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>{t("executors:portableConfigInfoLabel")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
|
||||
{content}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,10 @@ type SpritesSectionsProps = {
|
||||
remoteCredentials: string[];
|
||||
baselineRemoteCredentials?: string[];
|
||||
onRemoteCredentialsChange: (ids: string[]) => void;
|
||||
configBundleIds: string[];
|
||||
baselineConfigBundleIds?: string[];
|
||||
onConfigBundleChange: (ids: string[]) => void;
|
||||
isSSH?: boolean;
|
||||
agentEnvVars: Record<string, string | null>;
|
||||
baselineAgentEnvVars?: Record<string, string | null>;
|
||||
onAgentEnvVarChange: (agentId: string, secretId: string | null) => void;
|
||||
@@ -81,6 +85,10 @@ export function SpritesSections({
|
||||
remoteCredentials,
|
||||
baselineRemoteCredentials,
|
||||
onRemoteCredentialsChange,
|
||||
configBundleIds,
|
||||
baselineConfigBundleIds,
|
||||
onConfigBundleChange,
|
||||
isSSH,
|
||||
agentEnvVars,
|
||||
baselineAgentEnvVars,
|
||||
onAgentEnvVarChange,
|
||||
@@ -104,6 +112,10 @@ export function SpritesSections({
|
||||
selectedIds={remoteCredentials}
|
||||
baselineSelectedIds={baselineRemoteCredentials}
|
||||
onChange={onRemoteCredentialsChange}
|
||||
configBundleIds={configBundleIds}
|
||||
baselineConfigBundleIds={baselineConfigBundleIds}
|
||||
onConfigBundleChange={onConfigBundleChange}
|
||||
isSSH={isSSH}
|
||||
agentEnvVars={agentEnvVars}
|
||||
baselineAgentEnvVars={baselineAgentEnvVars}
|
||||
onAgentEnvVarChange={onAgentEnvVarChange}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { InlineSecretSelect } from "@/components/settings/profile-edit/inline-secret-select";
|
||||
import type { RemoteAuthMethod, RemoteAuthSpec } from "@/lib/api/domains/settings-api";
|
||||
import type { AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||
import { AgentConfigOptions } from "./portable-config-bundles";
|
||||
|
||||
const OPTION_LABEL_BASE =
|
||||
"flex min-h-11 w-full cursor-pointer items-start gap-3 rounded-md border p-3 text-left transition-colors";
|
||||
const SELECTED_BORDER = "border-primary bg-primary/5";
|
||||
const DEFAULT_BORDER = "border-border";
|
||||
const AGENT_LOGO_IDS = new Set(["claude_code", "auggie", "codex", "gemini", "copilot", "amp"]);
|
||||
|
||||
export function getSpecMethods(spec: RemoteAuthSpec): RemoteAuthMethod[] {
|
||||
return Array.isArray(spec.methods) ? spec.methods : [];
|
||||
}
|
||||
|
||||
type AuthSectionProps = {
|
||||
spec: RemoteAuthSpec;
|
||||
selectedIds: Set<string>;
|
||||
baselineSelectedIds: Set<string>;
|
||||
onCredentialsChange: (ids: string[]) => void;
|
||||
envSecretId: string | null;
|
||||
baselineEnvSecretId: string | null;
|
||||
onMethodSecretChange: (methodId: string, secretId: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
configBundles: AgentConfigBundle[];
|
||||
configBundleIds: string[];
|
||||
baselineConfigBundleIds: string[];
|
||||
onConfigBundleChange: (ids: string[]) => void;
|
||||
isSSH: boolean;
|
||||
};
|
||||
|
||||
export function AuthSection({
|
||||
spec,
|
||||
selectedIds,
|
||||
baselineSelectedIds,
|
||||
onCredentialsChange,
|
||||
envSecretId,
|
||||
baselineEnvSecretId,
|
||||
onMethodSecretChange,
|
||||
secrets,
|
||||
configBundles,
|
||||
configBundleIds,
|
||||
baselineConfigBundleIds,
|
||||
onConfigBundleChange,
|
||||
isSSH,
|
||||
}: AuthSectionProps) {
|
||||
const methods = getSpecMethods(spec);
|
||||
const envMethod = methods.find((m) => m.type === "env");
|
||||
const fileMethod = methods.find((m) => m.type === "files");
|
||||
const fileSelected = fileMethod ? selectedIds.has(fileMethod.method_id) : false;
|
||||
const envSelected = Boolean(envMethod && (selectedIds.has(envMethod.method_id) || envSecretId));
|
||||
const baselineFileSelected = fileMethod ? baselineSelectedIds.has(fileMethod.method_id) : false;
|
||||
const baselineEnvSelected = Boolean(
|
||||
envMethod && (baselineSelectedIds.has(envMethod.method_id) || baselineEnvSecretId),
|
||||
);
|
||||
const isDirty =
|
||||
fileSelected !== baselineFileSelected ||
|
||||
envSelected !== baselineEnvSelected ||
|
||||
envSecretId !== baselineEnvSecretId;
|
||||
const configIsDirty = configBundles.some(
|
||||
(bundle) => configBundleIds.includes(bundle.id) !== baselineConfigBundleIds.includes(bundle.id),
|
||||
);
|
||||
|
||||
const handleMethodToggle = (methodId: string, checked: boolean) => {
|
||||
const nextSelectedIds = new Set(selectedIds);
|
||||
setMethodSelected(nextSelectedIds, methodId, checked);
|
||||
if (envMethod?.method_id === methodId && !checked) {
|
||||
onMethodSecretChange(methodId, null);
|
||||
}
|
||||
onCredentialsChange([...nextSelectedIds]);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionItem value={spec.id} data-settings-dirty={isDirty || configIsDirty}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{AGENT_LOGO_IDS.has(spec.id) && <AgentLogo agentName={spec.id} size={18} />}
|
||||
<span className="font-medium text-sm">{spec.display_name}</span>
|
||||
<AuthStatusBadge
|
||||
fileSelected={fileSelected}
|
||||
envSelected={envSelected}
|
||||
hasSecret={!!envSecretId}
|
||||
/>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="h-auto">
|
||||
<div className="space-y-3 text-sm">
|
||||
<AuthOptions
|
||||
fileMethod={fileMethod}
|
||||
envMethod={envMethod}
|
||||
fileSelected={fileSelected}
|
||||
envSelected={envSelected}
|
||||
baselineFileSelected={baselineFileSelected}
|
||||
baselineEnvSelected={baselineEnvSelected}
|
||||
secretId={envSecretId}
|
||||
baselineSecretId={baselineEnvSecretId}
|
||||
onMethodToggle={handleMethodToggle}
|
||||
onSecretIdChange={(sid) => {
|
||||
if (envMethod) onMethodSecretChange(envMethod.method_id, sid);
|
||||
}}
|
||||
secrets={secrets}
|
||||
/>
|
||||
{configBundles.length > 0 && (
|
||||
<AgentConfigOptions
|
||||
agentId={spec.id}
|
||||
bundles={configBundles}
|
||||
selectedIds={configBundleIds}
|
||||
baselineSelectedIds={baselineConfigBundleIds}
|
||||
onChange={onConfigBundleChange}
|
||||
isSSH={isSSH}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthOptions({
|
||||
fileMethod,
|
||||
envMethod,
|
||||
fileSelected,
|
||||
envSelected,
|
||||
baselineFileSelected,
|
||||
baselineEnvSelected,
|
||||
secretId,
|
||||
baselineSecretId,
|
||||
onMethodToggle,
|
||||
onSecretIdChange,
|
||||
secrets,
|
||||
}: {
|
||||
fileMethod?: RemoteAuthMethod;
|
||||
envMethod?: RemoteAuthMethod;
|
||||
fileSelected: boolean;
|
||||
envSelected: boolean;
|
||||
baselineFileSelected: boolean;
|
||||
baselineEnvSelected: boolean;
|
||||
secretId: string | null;
|
||||
baselineSecretId: string | null;
|
||||
onMethodToggle: (methodId: string, checked: boolean) => void;
|
||||
onSecretIdChange: (id: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div role="group" aria-label={t("executors:remoteAuthMethod")} className="grid gap-2">
|
||||
{fileMethod && (
|
||||
<FileOption
|
||||
method={fileMethod}
|
||||
isSelected={fileSelected}
|
||||
isDirty={fileSelected !== baselineFileSelected}
|
||||
filesAvailable={fileMethod.has_local_files ?? false}
|
||||
onCheckedChange={(checked) => onMethodToggle(fileMethod.method_id, checked)}
|
||||
/>
|
||||
)}
|
||||
{envMethod?.env_var && (
|
||||
<EnvOption
|
||||
method={envMethod}
|
||||
isSelected={envSelected}
|
||||
isDirty={envSelected !== baselineEnvSelected || secretId !== baselineSecretId}
|
||||
secretId={secretId}
|
||||
baselineSecretId={baselineSecretId}
|
||||
onSecretIdChange={onSecretIdChange}
|
||||
secrets={secrets}
|
||||
onCheckedChange={(checked) => onMethodToggle(envMethod.method_id, checked)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileOption({
|
||||
method,
|
||||
isSelected,
|
||||
isDirty,
|
||||
filesAvailable,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
method: RemoteAuthMethod;
|
||||
isSelected: boolean;
|
||||
isDirty: boolean;
|
||||
filesAvailable: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const filesLabel = method.source_files?.join(", ") ?? "";
|
||||
return (
|
||||
<AuthOption
|
||||
selected={isSelected}
|
||||
isDirty={isDirty}
|
||||
onCheckedChange={onCheckedChange}
|
||||
label={method.label ?? t("executors:copyAuthFiles")}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{method.label ?? t("executors:copyAuthFiles")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filesAvailable
|
||||
? filesLabel
|
||||
: t("executors:authFilesNotFoundOnThisMachine", { files: filesLabel })}
|
||||
</span>
|
||||
</div>
|
||||
</AuthOption>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvOption({
|
||||
method,
|
||||
isSelected,
|
||||
isDirty,
|
||||
secretId,
|
||||
baselineSecretId,
|
||||
onSecretIdChange,
|
||||
secrets,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
method: RemoteAuthMethod;
|
||||
isSelected: boolean;
|
||||
isDirty: boolean;
|
||||
secretId: string | null;
|
||||
baselineSecretId: string | null;
|
||||
onSecretIdChange: (id: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<AuthOption
|
||||
selected={isSelected}
|
||||
isDirty={isDirty}
|
||||
onCheckedChange={onCheckedChange}
|
||||
label={t("executors:provideSecret")}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{t("executors:provideSecret")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans i18nKey="executors:setEnvVarViaStoredSecret" values={{ envVar: method.env_var }}>
|
||||
Set <code className="text-[11px] bg-muted px-1 rounded">{method.env_var}</code> via a
|
||||
stored secret
|
||||
</Trans>
|
||||
</span>
|
||||
{method.setup_hint && (
|
||||
<div className="markdown-body text-xs text-muted-foreground [&_p]:m-0">
|
||||
<ReactMarkdown>{method.setup_hint}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthOption>
|
||||
{isSelected && (
|
||||
<div className="pl-7 pt-2">
|
||||
<InlineSecretSelect
|
||||
secretId={secretId}
|
||||
onSecretIdChange={onSecretIdChange}
|
||||
secrets={secrets}
|
||||
placeholder={t("executors:selectOrCreateASecret")}
|
||||
isDirty={secretId !== baselineSecretId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthOption({
|
||||
selected,
|
||||
isDirty,
|
||||
onCheckedChange,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isDirty: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
data-settings-dirty={isDirty}
|
||||
className={`${OPTION_LABEL_BASE} ${selected ? SELECTED_BORDER : DEFAULT_BORDER}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => onCheckedChange(checked === true)}
|
||||
aria-label={label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function setMethodSelected(selectedIds: Set<string>, methodId: string, selected: boolean) {
|
||||
if (selected) {
|
||||
selectedIds.add(methodId);
|
||||
return;
|
||||
}
|
||||
selectedIds.delete(methodId);
|
||||
}
|
||||
|
||||
function AuthStatusBadge({
|
||||
fileSelected,
|
||||
envSelected,
|
||||
hasSecret,
|
||||
}: {
|
||||
fileSelected: boolean;
|
||||
envSelected: boolean;
|
||||
hasSecret: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (envSelected && hasSecret) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600 text-[10px] px-1.5 py-0">
|
||||
{t("executors:configured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (fileSelected) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600 text-[10px] px-1.5 py-0">
|
||||
{t("executors:filesSelected")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{t("executors:notConfigured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RemoteCredentialsCard } from "./remote-credentials-card";
|
||||
import { AgentConfigOptions } from "./portable-config-bundles";
|
||||
import { listRemoteCredentials } from "@/lib/api/domains/settings-api";
|
||||
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
listRemoteCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
const codexFileMethodId = "agent:codex-acp:files:0";
|
||||
const copyAuthFilesLabel = "Copy auth files";
|
||||
vi.mock("@/lib/api/domains/agent-config-api", () => ({
|
||||
listAgentConfigBundles: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderRemoteCredentialsCard(onChange = vi.fn()) {
|
||||
render(<RemoteCredentialsHarness onChange={onChange} />);
|
||||
const codexFileMethodId = "agent:codex-acp:files:0";
|
||||
const codexAgentName = "Codex";
|
||||
const copyAuthFilesLabel = "Copy auth files";
|
||||
const provideSecretLabel = "Provide secret";
|
||||
const codexConfigBundleId = "codex.config";
|
||||
const copyCodexConfigLabel = "Copy Codex configuration";
|
||||
const CHECKED_STATE = "checked";
|
||||
const UNCHECKED_STATE = "unchecked";
|
||||
const TRUE_ATTRIBUTE = "true";
|
||||
const DATA_STATE_ATTRIBUTE = "data-state";
|
||||
|
||||
function renderRemoteCredentialsCard(
|
||||
onChange = vi.fn(),
|
||||
onConfigChange = vi.fn(),
|
||||
initialConfigBundleIds: string[] = [],
|
||||
) {
|
||||
render(
|
||||
<RemoteCredentialsHarness
|
||||
onChange={onChange}
|
||||
onConfigChange={onConfigChange}
|
||||
initialConfigBundleIds={initialConfigBundleIds}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteCredentialsHarness({ onChange }: { onChange: (ids: string[]) => void }) {
|
||||
function RemoteCredentialsHarness({
|
||||
onChange,
|
||||
onConfigChange,
|
||||
initialConfigBundleIds,
|
||||
}: {
|
||||
onChange: (ids: string[]) => void;
|
||||
onConfigChange: (ids: string[]) => void;
|
||||
initialConfigBundleIds: string[];
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [configBundleIds, setConfigBundleIds] = useState(initialConfigBundleIds);
|
||||
return (
|
||||
<RemoteCredentialsCard
|
||||
isRemote={true}
|
||||
@@ -25,6 +58,11 @@ function RemoteCredentialsHarness({ onChange }: { onChange: (ids: string[]) => v
|
||||
setSelectedIds(ids);
|
||||
onChange(ids);
|
||||
}}
|
||||
configBundleIds={configBundleIds}
|
||||
onConfigBundleChange={(ids) => {
|
||||
setConfigBundleIds(ids);
|
||||
onConfigChange(ids);
|
||||
}}
|
||||
agentEnvVars={{}}
|
||||
onAgentEnvVarChange={() => {}}
|
||||
secrets={[]}
|
||||
@@ -40,11 +78,12 @@ function RemoteCredentialsHarness({ onChange }: { onChange: (ids: string[]) => v
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAgentConfigBundles).mockResolvedValue({ bundles: [] });
|
||||
vi.mocked(listRemoteCredentials).mockResolvedValue({
|
||||
auth_specs: [
|
||||
{
|
||||
id: "codex-acp",
|
||||
display_name: "Codex",
|
||||
display_name: codexAgentName,
|
||||
methods: [
|
||||
{
|
||||
method_id: codexFileMethodId,
|
||||
@@ -90,34 +129,37 @@ describe("RemoteCredentialsCard", () => {
|
||||
const onChange = vi.fn();
|
||||
renderRemoteCredentialsCard(onChange);
|
||||
|
||||
fireEvent.click(await screen.findByText("Codex"));
|
||||
const fileOption = screen.getByRole("radio", { name: copyAuthFilesLabel });
|
||||
expect(fileOption.getAttribute("aria-checked")).toBe("false");
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel });
|
||||
expect(fileOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(UNCHECKED_STATE);
|
||||
|
||||
fireEvent.click(fileOption);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([codexFileMethodId]);
|
||||
await waitFor(() => {
|
||||
const selectedOption = screen.getByRole("radio", { name: copyAuthFilesLabel });
|
||||
expect(selectedOption.getAttribute("aria-checked")).toBe("true");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("checkbox", { name: copyAuthFilesLabel })
|
||||
.getAttribute(DATA_STATE_ATTRIBUTE),
|
||||
).toBe(CHECKED_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the selected credential and owning card dirty", async () => {
|
||||
renderRemoteCredentialsCard();
|
||||
|
||||
fireEvent.click(await screen.findByText("Codex"));
|
||||
const fileOption = screen.getByRole("radio", { name: copyAuthFilesLabel });
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel });
|
||||
fireEvent.click(fileOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fileOption.getAttribute("data-settings-dirty")).toBe("true");
|
||||
expect(fileOption.closest("label")?.getAttribute("data-settings-dirty")).toBe(TRUE_ATTRIBUTE);
|
||||
expect(
|
||||
screen
|
||||
.getByText("Remote Credentials")
|
||||
.closest("[data-settings-dirty]")
|
||||
?.getAttribute("data-settings-dirty"),
|
||||
).toBe("true");
|
||||
).toBe(TRUE_ATTRIBUTE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,20 +169,184 @@ describe("RemoteCredentialsCard", () => {
|
||||
it("renders the env-var secret hint as one sentence with the variable inline", async () => {
|
||||
renderRemoteCredentialsCard();
|
||||
|
||||
fireEvent.click(await screen.findByText("Codex"));
|
||||
const envOption = screen.getByRole("radio", { name: "Provide secret" });
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
const envOption = screen.getByRole("checkbox", { name: provideSecretLabel });
|
||||
|
||||
expect(envOption.textContent).toContain("Set OPENAI_API_KEY via a stored secret");
|
||||
expect(envOption.querySelector("code")?.textContent).toBe("OPENAI_API_KEY");
|
||||
const envOptionLabel = envOption.closest("label");
|
||||
expect(envOptionLabel?.textContent).toContain("Set OPENAI_API_KEY via a stored secret");
|
||||
expect(envOptionLabel?.querySelector("code")?.textContent).toBe("OPENAI_API_KEY");
|
||||
});
|
||||
|
||||
it("appends the not-found note to the auth file list as one string", async () => {
|
||||
renderRemoteCredentialsCard();
|
||||
|
||||
fireEvent.click(await screen.findByText("Codex"));
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
|
||||
expect(
|
||||
screen.getByText(".codex/auth.json, .codex/config.toml; files not found on this machine"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("places configuration choices inside the matching agent accordion", async () => {
|
||||
vi.mocked(listAgentConfigBundles).mockResolvedValueOnce({
|
||||
bundles: [PORTABLE_BUNDLES[1]],
|
||||
});
|
||||
|
||||
renderRemoteCredentialsCard();
|
||||
|
||||
await screen.findByText(codexAgentName);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("checkbox", { name: copyCodexConfigLabel })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(codexAgentName));
|
||||
|
||||
const options = await screen.findByTestId("agent-config-options-codex-acp");
|
||||
expect(options).toBeTruthy();
|
||||
expect(screen.queryByText("Agent configuration")).toBeNull();
|
||||
expect(options.contains(screen.getByRole("checkbox", { name: copyCodexConfigLabel }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authentication and configuration selections independent inside one agent", async () => {
|
||||
vi.mocked(listAgentConfigBundles).mockResolvedValueOnce({
|
||||
bundles: [{ ...PORTABLE_BUNDLES[1], available: true }],
|
||||
});
|
||||
const onAuthChange = vi.fn();
|
||||
const onConfigChange = vi.fn();
|
||||
|
||||
renderRemoteCredentialsCard(onAuthChange, onConfigChange);
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
|
||||
const authOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel });
|
||||
fireEvent.click(authOption);
|
||||
const configOptions = await screen.findByTestId("agent-config-options-codex-acp");
|
||||
fireEvent.click(within(configOptions).getByRole("checkbox", { name: copyCodexConfigLabel }));
|
||||
|
||||
expect(authOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||
expect(onAuthChange).toHaveBeenCalledWith([codexFileMethodId]);
|
||||
expect(onConfigChange).toHaveBeenCalledWith([codexConfigBundleId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteCredentialsCard auth selections", () => {
|
||||
it("allows file and secret authentication methods at the same time", async () => {
|
||||
const onAuthChange = vi.fn();
|
||||
renderRemoteCredentialsCard(onAuthChange);
|
||||
|
||||
fireEvent.click(await screen.findByText(codexAgentName));
|
||||
const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel });
|
||||
const secretOption = screen.getByRole("checkbox", { name: provideSecretLabel });
|
||||
|
||||
fireEvent.click(fileOption);
|
||||
fireEvent.click(secretOption);
|
||||
|
||||
expect(fileOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||
expect(secretOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||
expect(onAuthChange).toHaveBeenLastCalledWith([
|
||||
codexFileMethodId,
|
||||
"agent:codex-acp:env:OPENAI_API_KEY",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const PORTABLE_BUNDLES: AgentConfigBundle[] = [
|
||||
{
|
||||
id: "claude.settings",
|
||||
agent_id: "claude-acp",
|
||||
display_name: "Claude settings",
|
||||
label: "Claude settings",
|
||||
available: true,
|
||||
files: [
|
||||
{
|
||||
source_path: ".claude/settings.json",
|
||||
target_path: ".claude/settings.json",
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: codexConfigBundleId,
|
||||
agent_id: "codex-acp",
|
||||
display_name: "Codex configuration",
|
||||
label: "Codex configuration",
|
||||
available: false,
|
||||
files: [
|
||||
{ source_path: ".codex/config.toml", target_path: ".codex/config.toml", available: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("AgentConfigOptions", () => {
|
||||
it("keeps configuration selection independent from authentication", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentConfigOptions
|
||||
agentId="codex-acp"
|
||||
bundles={PORTABLE_BUNDLES}
|
||||
selectedIds={[]}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Copy Claude settings" }));
|
||||
rerender(
|
||||
<AgentConfigOptions
|
||||
agentId="codex-acp"
|
||||
bundles={PORTABLE_BUNDLES}
|
||||
selectedIds={["claude.settings"]}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["claude.settings"]);
|
||||
expect(
|
||||
screen.getByTestId("agent-config-options-codex-acp").getAttribute("data-settings-dirty"),
|
||||
).toBe(TRUE_ATTRIBUTE);
|
||||
});
|
||||
|
||||
it("disables new unavailable bundles but keeps saved ones removable", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<AgentConfigOptions
|
||||
agentId="codex-acp"
|
||||
bundles={PORTABLE_BUNDLES}
|
||||
selectedIds={[]}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: copyCodexConfigLabel });
|
||||
expect((checkbox as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByText("Not found")).toBeTruthy();
|
||||
expect(screen.getByText(/editing hint/i)).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<AgentConfigOptions
|
||||
agentId="codex-acp"
|
||||
bundles={PORTABLE_BUNDLES}
|
||||
selectedIds={[codexConfigBundleId]}
|
||||
baselineSelectedIds={[codexConfigBundleId]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const savedCheckbox = screen.getByRole("checkbox", { name: copyCodexConfigLabel });
|
||||
expect((savedCheckbox as HTMLInputElement).disabled).toBe(false);
|
||||
fireEvent.click(savedCheckbox);
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
rerender(
|
||||
<AgentConfigOptions
|
||||
agentId="codex-acp"
|
||||
bundles={PORTABLE_BUNDLES}
|
||||
selectedIds={[]}
|
||||
baselineSelectedIds={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { InlineSecretSelect } from "@/components/settings/profile-edit/inline-secret-select";
|
||||
import { Accordion } from "@kandev/ui/accordion";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import {
|
||||
GitIdentityAccordionItem,
|
||||
type GitIdentityMode,
|
||||
type GitIdentityState,
|
||||
} from "./git-identity-fields";
|
||||
import {
|
||||
listRemoteCredentials,
|
||||
type RemoteAuthSpec,
|
||||
type RemoteAuthMethod,
|
||||
} from "@/lib/api/domains/settings-api";
|
||||
import { listRemoteCredentials, type RemoteAuthSpec } from "@/lib/api/domains/settings-api";
|
||||
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AuthChoice = "files" | "env" | "none";
|
||||
import { AuthSection, getSpecMethods } from "./remote-auth-section";
|
||||
export type { GitIdentityMode, GitIdentityState } from "./git-identity-fields";
|
||||
|
||||
const RADIO_LABEL_BASE =
|
||||
"flex w-full items-start gap-3 rounded-md border p-3 text-left cursor-pointer transition-colors";
|
||||
const SELECTED_BORDER = "border-primary bg-primary/5";
|
||||
const DEFAULT_BORDER = "border-border";
|
||||
const OPTION_DOT_BASE =
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border";
|
||||
|
||||
type RemoteCredentialsCardProps = {
|
||||
isRemote: boolean;
|
||||
selectedIds: string[];
|
||||
baselineSelectedIds?: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
configBundleIds: string[];
|
||||
baselineConfigBundleIds?: string[];
|
||||
onConfigBundleChange: (ids: string[]) => void;
|
||||
isSSH?: boolean;
|
||||
agentEnvVars: Record<string, string | null>;
|
||||
baselineAgentEnvVars?: Record<string, string | null>;
|
||||
onAgentEnvVarChange: (methodId: string, secretId: string | null) => void;
|
||||
@@ -59,6 +47,10 @@ export function RemoteCredentialsCard({
|
||||
selectedIds,
|
||||
baselineSelectedIds = [],
|
||||
onChange,
|
||||
configBundleIds,
|
||||
baselineConfigBundleIds = [],
|
||||
onConfigBundleChange,
|
||||
isSSH = false,
|
||||
agentEnvVars,
|
||||
baselineAgentEnvVars = {},
|
||||
onAgentEnvVarChange,
|
||||
@@ -74,26 +66,24 @@ export function RemoteCredentialsCard({
|
||||
onGitUserEmailChange,
|
||||
localGitIdentity,
|
||||
}: RemoteCredentialsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authSpecs, setAuthSpecs] = useState<RemoteAuthSpec[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
listRemoteCredentials()
|
||||
.then((res) => setAuthSpecs(res.auth_specs ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const { authSpecs, configBundles, loading } = useRemoteCredentialCatalog();
|
||||
const isDirty = hasRemoteCredentialsChanges({
|
||||
selectedIds,
|
||||
baselineSelectedIds,
|
||||
configBundleIds,
|
||||
baselineConfigBundleIds,
|
||||
agentEnvVars,
|
||||
baselineAgentEnvVars,
|
||||
gitIdentityMode,
|
||||
baselineGitIdentityMode,
|
||||
gitUserName,
|
||||
gitUserEmail,
|
||||
baselineGitUserName,
|
||||
baselineGitUserEmail,
|
||||
});
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const baselineSelectedSet = new Set(baselineSelectedIds);
|
||||
const credentialsDirty = !sameStringSet(selectedSet, baselineSelectedSet);
|
||||
const agentEnvVarsDirty = JSON.stringify(agentEnvVars) !== JSON.stringify(baselineAgentEnvVars);
|
||||
const gitIdentityDirty =
|
||||
gitIdentityMode !== baselineGitIdentityMode ||
|
||||
(gitIdentityMode === "override" &&
|
||||
(gitUserName !== baselineGitUserName || gitUserEmail !== baselineGitUserEmail));
|
||||
const isDirty = credentialsDirty || agentEnvVarsDirty || gitIdentityDirty;
|
||||
const agentSections = mergeAgentSections(authSpecs, configBundles);
|
||||
|
||||
if (loading) {
|
||||
return <RemoteCredentialsLoading isDirty={isDirty} />;
|
||||
@@ -101,57 +91,76 @@ export function RemoteCredentialsCard({
|
||||
|
||||
return (
|
||||
<SettingsCard isDirty={isDirty}>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("executors:remoteCredentials")}</CardTitle>
|
||||
<CardDescription>{t("executors:configureAuthenticationForToolsAndAgents")}</CardDescription>
|
||||
</CardHeader>
|
||||
<RemoteCredentialsHeader />
|
||||
<CardContent className="space-y-4">
|
||||
{authSpecs.length > 0 || isRemote ? (
|
||||
<Accordion type="multiple">
|
||||
{isRemote && (
|
||||
<GitIdentityAccordionItem
|
||||
mode={gitIdentityMode}
|
||||
baselineMode={baselineGitIdentityMode}
|
||||
onModeChange={onGitIdentityModeChange}
|
||||
gitUserName={gitUserName}
|
||||
gitUserEmail={gitUserEmail}
|
||||
baselineGitUserName={baselineGitUserName}
|
||||
baselineGitUserEmail={baselineGitUserEmail}
|
||||
onGitUserNameChange={onGitUserNameChange}
|
||||
onGitUserEmailChange={onGitUserEmailChange}
|
||||
localGitIdentity={localGitIdentity}
|
||||
/>
|
||||
)}
|
||||
{authSpecs.map((spec) => {
|
||||
const methods = getSpecMethods(spec);
|
||||
const envMethod = methods.find((m) => m.type === "env");
|
||||
return (
|
||||
<AuthSection
|
||||
key={spec.id}
|
||||
spec={spec}
|
||||
selectedIds={selectedSet}
|
||||
baselineSelectedIds={baselineSelectedSet}
|
||||
onCredentialsChange={onChange}
|
||||
envSecretId={envMethod ? (agentEnvVars[envMethod.method_id] ?? null) : null}
|
||||
baselineEnvSecretId={
|
||||
envMethod ? (baselineAgentEnvVars[envMethod.method_id] ?? null) : null
|
||||
}
|
||||
onMethodSecretChange={onAgentEnvVarChange}
|
||||
secrets={secrets}
|
||||
{agentSections.length > 0 || isRemote ? (
|
||||
<>
|
||||
<Accordion type="multiple">
|
||||
{isRemote && (
|
||||
<GitIdentityAccordionItem
|
||||
mode={gitIdentityMode}
|
||||
baselineMode={baselineGitIdentityMode}
|
||||
onModeChange={onGitIdentityModeChange}
|
||||
gitUserName={gitUserName}
|
||||
gitUserEmail={gitUserEmail}
|
||||
baselineGitUserName={baselineGitUserName}
|
||||
baselineGitUserEmail={baselineGitUserEmail}
|
||||
onGitUserNameChange={onGitUserNameChange}
|
||||
onGitUserEmailChange={onGitUserEmailChange}
|
||||
localGitIdentity={localGitIdentity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
{agentSections.map(({ spec, configBundles: agentConfigBundles }) => {
|
||||
const methods = getSpecMethods(spec);
|
||||
const envMethod = methods.find((m) => m.type === "env");
|
||||
return (
|
||||
<AuthSection
|
||||
key={spec.id}
|
||||
spec={spec}
|
||||
selectedIds={selectedSet}
|
||||
baselineSelectedIds={baselineSelectedSet}
|
||||
onCredentialsChange={onChange}
|
||||
envSecretId={envMethod ? (agentEnvVars[envMethod.method_id] ?? null) : null}
|
||||
baselineEnvSecretId={
|
||||
envMethod ? (baselineAgentEnvVars[envMethod.method_id] ?? null) : null
|
||||
}
|
||||
onMethodSecretChange={onAgentEnvVarChange}
|
||||
secrets={secrets}
|
||||
configBundles={agentConfigBundles}
|
||||
configBundleIds={configBundleIds}
|
||||
baselineConfigBundleIds={baselineConfigBundleIds}
|
||||
onConfigBundleChange={onConfigBundleChange}
|
||||
isSSH={isSSH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("executors:noTransferableCredentialsFound")}
|
||||
</p>
|
||||
<NoTransferableCredentials />
|
||||
)}
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteCredentialsHeader() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<CardHeader>
|
||||
<CardTitle>{t("executors:remoteCredentials")}</CardTitle>
|
||||
<CardDescription>{t("executors:configureAuthenticationForToolsAndAgents")}</CardDescription>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function NoTransferableCredentials() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">{t("executors:noTransferableCredentialsFound")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteCredentialsLoading({ isDirty }: { isDirty: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -169,372 +178,100 @@ function RemoteCredentialsLoading({ isDirty }: { isDirty: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function useRemoteCredentialCatalog() {
|
||||
const [authSpecs, setAuthSpecs] = useState<RemoteAuthSpec[]>([]);
|
||||
const [configBundles, setConfigBundles] = useState<AgentConfigBundle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.allSettled([listRemoteCredentials(), listAgentConfigBundles()]).then(
|
||||
([authResult, configResult]) => {
|
||||
if (authResult.status === "fulfilled") setAuthSpecs(authResult.value.auth_specs ?? []);
|
||||
if (configResult.status === "fulfilled") setConfigBundles(configResult.value.bundles ?? []);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { authSpecs, configBundles, loading };
|
||||
}
|
||||
|
||||
type RemoteAgentSection = {
|
||||
spec: RemoteAuthSpec;
|
||||
configBundles: AgentConfigBundle[];
|
||||
};
|
||||
|
||||
function mergeAgentSections(
|
||||
authSpecs: RemoteAuthSpec[],
|
||||
configBundles: AgentConfigBundle[],
|
||||
): RemoteAgentSection[] {
|
||||
const sections = new Map<string, RemoteAgentSection>();
|
||||
|
||||
for (const spec of authSpecs) {
|
||||
sections.set(spec.id, { spec, configBundles: [] });
|
||||
}
|
||||
|
||||
for (const bundle of configBundles) {
|
||||
const section = sections.get(bundle.agent_id);
|
||||
if (section) {
|
||||
section.configBundles.push(bundle);
|
||||
continue;
|
||||
}
|
||||
|
||||
sections.set(bundle.agent_id, {
|
||||
spec: {
|
||||
id: bundle.agent_id,
|
||||
display_name: bundle.display_name,
|
||||
methods: [],
|
||||
},
|
||||
configBundles: [bundle],
|
||||
});
|
||||
}
|
||||
|
||||
return [...sections.values()];
|
||||
}
|
||||
|
||||
function hasRemoteCredentialsChanges({
|
||||
selectedIds,
|
||||
baselineSelectedIds,
|
||||
configBundleIds,
|
||||
baselineConfigBundleIds,
|
||||
agentEnvVars,
|
||||
baselineAgentEnvVars,
|
||||
gitIdentityMode,
|
||||
baselineGitIdentityMode,
|
||||
gitUserName,
|
||||
gitUserEmail,
|
||||
baselineGitUserName,
|
||||
baselineGitUserEmail,
|
||||
}: Pick<
|
||||
RemoteCredentialsCardProps,
|
||||
| "selectedIds"
|
||||
| "baselineSelectedIds"
|
||||
| "configBundleIds"
|
||||
| "baselineConfigBundleIds"
|
||||
| "agentEnvVars"
|
||||
| "baselineAgentEnvVars"
|
||||
| "gitIdentityMode"
|
||||
| "baselineGitIdentityMode"
|
||||
| "gitUserName"
|
||||
| "gitUserEmail"
|
||||
| "baselineGitUserName"
|
||||
| "baselineGitUserEmail"
|
||||
>): boolean {
|
||||
const credentialsDirty = !sameStringSet(new Set(selectedIds), new Set(baselineSelectedIds));
|
||||
const configBundlesDirty = !sameStringSet(
|
||||
new Set(configBundleIds),
|
||||
new Set(baselineConfigBundleIds),
|
||||
);
|
||||
const agentEnvVarsDirty = JSON.stringify(agentEnvVars) !== JSON.stringify(baselineAgentEnvVars);
|
||||
const gitIdentityDirty =
|
||||
gitIdentityMode !== baselineGitIdentityMode ||
|
||||
(gitIdentityMode === "override" &&
|
||||
(gitUserName !== baselineGitUserName || gitUserEmail !== baselineGitUserEmail));
|
||||
return credentialsDirty || configBundlesDirty || agentEnvVarsDirty || gitIdentityDirty;
|
||||
}
|
||||
|
||||
function sameStringSet(left: Set<string>, right: Set<string>): boolean {
|
||||
return left.size === right.size && [...left].every((value) => right.has(value));
|
||||
}
|
||||
|
||||
function getSpecMethods(spec: RemoteAuthSpec): RemoteAuthMethod[] {
|
||||
return Array.isArray(spec.methods) ? spec.methods : [];
|
||||
}
|
||||
|
||||
type InitialChoiceOpts = {
|
||||
fileMethod: RemoteAuthMethod | undefined;
|
||||
envMethod: RemoteAuthMethod | undefined;
|
||||
selectedIds: Set<string>;
|
||||
envSecretId: string | null;
|
||||
};
|
||||
|
||||
function initialChoice(opts: InitialChoiceOpts): AuthChoice {
|
||||
if (opts.fileMethod && opts.selectedIds.has(opts.fileMethod.method_id)) return "files";
|
||||
// Treat env as selected either when the user just clicked the radio (its
|
||||
// method id is in selectedIds, no secret picked yet) or when a secret is
|
||||
// already persisted. Without the selectedIds branch, first-time env setup
|
||||
// for an agent that exposes both `files` and `env` methods is broken: the
|
||||
// radio click never updates state, `choice` re-derives to "none", the env
|
||||
// option deselects, and the secret dropdown disappears before the user can
|
||||
// pick anything.
|
||||
if (opts.envMethod && (opts.selectedIds.has(opts.envMethod.method_id) || opts.envSecretId))
|
||||
return "env";
|
||||
return "none";
|
||||
}
|
||||
|
||||
const AGENT_LOGO_IDS = new Set(["claude_code", "auggie", "codex", "gemini", "copilot", "amp"]);
|
||||
|
||||
function AuthSection({
|
||||
spec,
|
||||
selectedIds,
|
||||
baselineSelectedIds,
|
||||
onCredentialsChange,
|
||||
envSecretId,
|
||||
baselineEnvSecretId,
|
||||
onMethodSecretChange,
|
||||
secrets,
|
||||
}: {
|
||||
spec: RemoteAuthSpec;
|
||||
selectedIds: Set<string>;
|
||||
baselineSelectedIds: Set<string>;
|
||||
onCredentialsChange: (ids: string[]) => void;
|
||||
envSecretId: string | null;
|
||||
baselineEnvSecretId: string | null;
|
||||
onMethodSecretChange: (methodId: string, secretId: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
}) {
|
||||
const methods = getSpecMethods(spec);
|
||||
const envMethod = methods.find((m) => m.type === "env");
|
||||
const fileMethod = methods.find((m) => m.type === "files");
|
||||
const hasOnlyEnv = envMethod && !fileMethod;
|
||||
|
||||
// `choice` is derived from props so the configured-status badge updates live
|
||||
// when the user picks a secret in the dropdown (which only flows back through
|
||||
// `envSecretId`). Holding it in useState would freeze the badge to its initial
|
||||
// value until a full page reload.
|
||||
const choice: AuthChoice = initialChoice({
|
||||
fileMethod,
|
||||
envMethod,
|
||||
selectedIds,
|
||||
envSecretId,
|
||||
});
|
||||
const baselineChoice = initialChoice({
|
||||
fileMethod,
|
||||
envMethod,
|
||||
selectedIds: baselineSelectedIds,
|
||||
envSecretId: baselineEnvSecretId,
|
||||
});
|
||||
const isDirty = choice !== baselineChoice || envSecretId !== baselineEnvSecretId;
|
||||
|
||||
const handleChoice = (value: AuthChoice) => {
|
||||
const nextSelectedIds = new Set(selectedIds);
|
||||
if (fileMethod) {
|
||||
setMethodSelected(nextSelectedIds, fileMethod.method_id, value === "files");
|
||||
}
|
||||
if (envMethod) {
|
||||
// Track env in selectedIds so `initialChoice` stays "env" while the
|
||||
// user is still picking a secret. Switching away clears the secret too.
|
||||
setMethodSelected(nextSelectedIds, envMethod.method_id, value === "env");
|
||||
if (value !== "env") {
|
||||
onMethodSecretChange(envMethod.method_id, null);
|
||||
}
|
||||
}
|
||||
onCredentialsChange([...nextSelectedIds]);
|
||||
};
|
||||
|
||||
const showLogo = AGENT_LOGO_IDS.has(spec.id);
|
||||
|
||||
return (
|
||||
<AccordionItem value={spec.id} data-settings-dirty={isDirty}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{showLogo && <AgentLogo agentName={spec.id} size={18} />}
|
||||
<span className="font-medium text-sm">{spec.display_name}</span>
|
||||
<AuthStatusBadge choice={choice} hasSecret={!!envSecretId} />
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="h-auto">
|
||||
<div className="space-y-3 text-sm">
|
||||
{hasOnlyEnv && envMethod ? (
|
||||
<EnvOnlySection
|
||||
envMethod={envMethod}
|
||||
secretId={envSecretId}
|
||||
baselineSecretId={baselineEnvSecretId}
|
||||
onSecretIdChange={(sid) => onMethodSecretChange(envMethod.method_id, sid)}
|
||||
secrets={secrets}
|
||||
/>
|
||||
) : (
|
||||
<AuthChoiceRadio
|
||||
choice={choice}
|
||||
baselineChoice={baselineChoice}
|
||||
onChoiceChange={handleChoice}
|
||||
fileMethod={fileMethod}
|
||||
envMethod={envMethod}
|
||||
secretId={envSecretId}
|
||||
baselineSecretId={baselineEnvSecretId}
|
||||
onSecretIdChange={(sid) => {
|
||||
if (envMethod) onMethodSecretChange(envMethod.method_id, sid);
|
||||
}}
|
||||
secrets={secrets}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvOnlySection({
|
||||
envMethod,
|
||||
secretId,
|
||||
baselineSecretId,
|
||||
onSecretIdChange,
|
||||
secrets,
|
||||
}: {
|
||||
envMethod: RemoteAuthMethod;
|
||||
secretId: string | null;
|
||||
baselineSecretId: string | null;
|
||||
onSecretIdChange: (id: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{envMethod.setup_hint && (
|
||||
<div className="markdown-body text-xs text-muted-foreground [&_p]:m-0">
|
||||
<ReactMarkdown>{envMethod.setup_hint}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<InlineSecretSelect
|
||||
secretId={secretId}
|
||||
onSecretIdChange={onSecretIdChange}
|
||||
secrets={secrets}
|
||||
label={envMethod.env_var}
|
||||
placeholder={t("executors:selectOrCreateASecret")}
|
||||
isDirty={secretId !== baselineSecretId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileOption({
|
||||
method,
|
||||
isSelected,
|
||||
isDirty,
|
||||
filesAvailable,
|
||||
onSelect,
|
||||
}: {
|
||||
method: RemoteAuthMethod;
|
||||
isSelected: boolean;
|
||||
isDirty: boolean;
|
||||
filesAvailable: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const filesLabel = method.source_files?.join(", ") ?? "";
|
||||
return (
|
||||
<AuthOptionButton
|
||||
selected={isSelected}
|
||||
isDirty={isDirty}
|
||||
onSelect={onSelect}
|
||||
label={method.label ?? t("executors:copyAuthFiles")}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{method.label ?? t("executors:copyAuthFiles")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filesAvailable
|
||||
? filesLabel
|
||||
: t("executors:authFilesNotFoundOnThisMachine", { files: filesLabel })}
|
||||
</span>
|
||||
</div>
|
||||
</AuthOptionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvOption({
|
||||
method,
|
||||
isSelected,
|
||||
isDirty,
|
||||
secretId,
|
||||
baselineSecretId,
|
||||
onSecretIdChange,
|
||||
secrets,
|
||||
onSelect,
|
||||
}: {
|
||||
method: RemoteAuthMethod;
|
||||
isSelected: boolean;
|
||||
isDirty: boolean;
|
||||
secretId: string | null;
|
||||
baselineSecretId: string | null;
|
||||
onSecretIdChange: (id: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<AuthOptionButton
|
||||
selected={isSelected}
|
||||
isDirty={isDirty}
|
||||
onSelect={onSelect}
|
||||
label={t("executors:provideSecret")}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{t("executors:provideSecret")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans i18nKey="executors:setEnvVarViaStoredSecret" values={{ envVar: method.env_var }}>
|
||||
Set <code className="text-[11px] bg-muted px-1 rounded">{method.env_var}</code> via a
|
||||
stored secret
|
||||
</Trans>
|
||||
</span>
|
||||
{method.setup_hint && (
|
||||
<div className="markdown-body text-xs text-muted-foreground [&_p]:m-0">
|
||||
<ReactMarkdown>{method.setup_hint}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthOptionButton>
|
||||
{isSelected && (
|
||||
<div className="pl-7 pt-2">
|
||||
<InlineSecretSelect
|
||||
secretId={secretId}
|
||||
onSecretIdChange={onSecretIdChange}
|
||||
secrets={secrets}
|
||||
placeholder={t("executors:selectOrCreateASecret")}
|
||||
isDirty={secretId !== baselineSecretId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthChoiceRadio({
|
||||
choice,
|
||||
baselineChoice,
|
||||
onChoiceChange,
|
||||
fileMethod,
|
||||
envMethod,
|
||||
secretId,
|
||||
baselineSecretId,
|
||||
onSecretIdChange,
|
||||
secrets,
|
||||
}: {
|
||||
choice: AuthChoice;
|
||||
baselineChoice: AuthChoice;
|
||||
onChoiceChange: (v: AuthChoice) => void;
|
||||
fileMethod?: RemoteAuthMethod;
|
||||
envMethod?: RemoteAuthMethod;
|
||||
secretId: string | null;
|
||||
baselineSecretId: string | null;
|
||||
onSecretIdChange: (id: string | null) => void;
|
||||
secrets: SecretListItem[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t("executors:remoteAuthMethod")} className="grid gap-0">
|
||||
{fileMethod && (
|
||||
<FileOption
|
||||
method={fileMethod}
|
||||
isSelected={choice === "files"}
|
||||
isDirty={(choice === "files") !== (baselineChoice === "files")}
|
||||
filesAvailable={fileMethod.has_local_files ?? false}
|
||||
onSelect={() => onChoiceChange("files")}
|
||||
/>
|
||||
)}
|
||||
{envMethod?.env_var && (
|
||||
<EnvOption
|
||||
method={envMethod}
|
||||
isSelected={choice === "env"}
|
||||
isDirty={(choice === "env") !== (baselineChoice === "env")}
|
||||
secretId={secretId}
|
||||
baselineSecretId={baselineSecretId}
|
||||
onSecretIdChange={onSecretIdChange}
|
||||
secrets={secrets}
|
||||
onSelect={() => onChoiceChange("env")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthOptionButton({
|
||||
selected,
|
||||
isDirty,
|
||||
onSelect,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isDirty: boolean;
|
||||
onSelect: () => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={label}
|
||||
onClick={onSelect}
|
||||
data-settings-dirty={isDirty}
|
||||
className={`${RADIO_LABEL_BASE} ${selected ? SELECTED_BORDER : DEFAULT_BORDER}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${OPTION_DOT_BASE} ${selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/80"}`}
|
||||
>
|
||||
{selected && <span className="size-2 rounded-full bg-current" />}
|
||||
</span>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function setMethodSelected(selectedIds: Set<string>, methodId: string, selected: boolean) {
|
||||
if (selected) {
|
||||
selectedIds.add(methodId);
|
||||
return;
|
||||
}
|
||||
selectedIds.delete(methodId);
|
||||
}
|
||||
|
||||
function AuthStatusBadge({ choice, hasSecret }: { choice: AuthChoice; hasSecret: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (choice === "env" && hasSecret) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600 text-[10px] px-1.5 py-0">
|
||||
{t("executors:configured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (choice === "files") {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600 text-[10px] px-1.5 py-0">
|
||||
{t("executors:filesSelected")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{t("executors:notConfigured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSaveConfig, type ExecutorProfileConfigForm } from "./serialize-executor-config";
|
||||
|
||||
function form(overrides: Partial<ExecutorProfileConfigForm> = {}): ExecutorProfileConfigForm {
|
||||
return {
|
||||
isSprites: false,
|
||||
networkPolicyRules: [],
|
||||
isRemote: true,
|
||||
remoteCredentials: [],
|
||||
configBundleIds: [],
|
||||
agentEnvVars: {},
|
||||
gitIdentityMode: "override",
|
||||
localGitIdentity: { userName: "", userEmail: "" },
|
||||
gitUserName: "",
|
||||
gitUserEmail: "",
|
||||
isDocker: false,
|
||||
dockerfile: "",
|
||||
imageTag: "",
|
||||
isSSH: false,
|
||||
sshShell: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSaveConfig", () => {
|
||||
it("persists selected configuration without requiring authentication", () => {
|
||||
const config = buildSaveConfig(form({ configBundleIds: ["mock.settings"] }), {
|
||||
remote_credentials: "stale",
|
||||
keep: "yes",
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
agent_config_bundles: '["mock.settings"]',
|
||||
keep: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists authentication without requiring configuration", () => {
|
||||
const config = buildSaveConfig(form({ remoteCredentials: ["codex-auth"] }));
|
||||
|
||||
expect(config).toEqual({ remote_credentials: '["codex-auth"]' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api";
|
||||
|
||||
export type ExecutorProfileConfigForm = {
|
||||
isSprites: boolean;
|
||||
networkPolicyRules: NetworkPolicyRule[];
|
||||
isRemote: boolean;
|
||||
remoteCredentials: string[];
|
||||
configBundleIds: string[];
|
||||
agentEnvVars: Record<string, string | null>;
|
||||
gitIdentityMode: "local" | "override";
|
||||
localGitIdentity: { userName: string; userEmail: string };
|
||||
gitUserName: string;
|
||||
gitUserEmail: string;
|
||||
isDocker: boolean;
|
||||
dockerfile: string;
|
||||
imageTag: string;
|
||||
isSSH: boolean;
|
||||
sshShell: string;
|
||||
};
|
||||
|
||||
export function buildSaveConfig(
|
||||
form: ExecutorProfileConfigForm,
|
||||
baseConfig: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const config = { ...baseConfig };
|
||||
setJsonConfig(config, "sprites_network_policy_rules", form.isSprites, form.networkPolicyRules);
|
||||
setJsonConfig(config, "remote_credentials", form.isRemote, form.remoteCredentials);
|
||||
setJsonConfig(config, "agent_config_bundles", form.isRemote, form.configBundleIds);
|
||||
const envVars = Object.fromEntries(
|
||||
Object.entries(form.agentEnvVars).filter(([, v]) => v != null),
|
||||
);
|
||||
setJsonConfig(config, "remote_auth_secrets", form.isRemote, envVars);
|
||||
|
||||
const gitName =
|
||||
form.gitIdentityMode === "local" ? form.localGitIdentity.userName : form.gitUserName;
|
||||
const gitEmail =
|
||||
form.gitIdentityMode === "local" ? form.localGitIdentity.userEmail : form.gitUserEmail;
|
||||
setTextConfig(config, "git_user_name", form.isRemote ? gitName.trim() : "");
|
||||
setTextConfig(config, "git_user_email", form.isRemote ? gitEmail.trim() : "");
|
||||
setTextConfig(config, "dockerfile", form.isDocker ? form.dockerfile : "");
|
||||
setTextConfig(config, "image_tag", form.isDocker ? form.imageTag.trim() : "");
|
||||
setTextConfig(config, "ssh_shell", form.isSSH ? form.sshShell.trim() : "");
|
||||
return config;
|
||||
}
|
||||
|
||||
function setJsonConfig(
|
||||
config: Record<string, string>,
|
||||
key: string,
|
||||
enabled: boolean,
|
||||
value: unknown,
|
||||
): void {
|
||||
const hasValue = Array.isArray(value)
|
||||
? value.length > 0
|
||||
: value !== null && typeof value === "object" && Object.keys(value).length > 0;
|
||||
if (enabled && hasValue) config[key] = JSON.stringify(value);
|
||||
else delete config[key];
|
||||
}
|
||||
|
||||
function setTextConfig(config: Record<string, string>, key: string, value: string): void {
|
||||
if (value && value.trim()) config[key] = value;
|
||||
else delete config[key];
|
||||
}
|
||||
@@ -147,11 +147,11 @@ describe("useAgentProfileOptions enabled filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentProfileOptions no-silent-model-fallback gating", () => {
|
||||
it("blocks a profile whose start model is gone (strict mode)", () => {
|
||||
describe("useAgentProfileOptions executor-authoritative model hint", () => {
|
||||
it("keeps a profile whose start model is absent from the host probe selectable", () => {
|
||||
const option = renderOptions([profileOption({ model: GONE_MODEL })]);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBe("true");
|
||||
expect(option.getAttribute("data-reason")).toContain(GONE_MODEL);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
|
||||
expect(screen.getByTitle(/claude-gone/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a profile with an available start model selectable", () => {
|
||||
@@ -164,27 +164,26 @@ describe("useAgentProfileOptions no-silent-model-fallback gating", () => {
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a gone-model profile with a fallback selectable and warns", () => {
|
||||
it("keeps a gone-model profile with a fallback selectable", () => {
|
||||
const option = renderOptions([profileOption({ model: GONE_MODEL, fallback_model: "gpt-5" })]);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
|
||||
expect(option.getAttribute("data-reason")).toBeNull();
|
||||
|
||||
// The amber warning icon carries the fallback note.
|
||||
expect(screen.getByTitle(/gpt-5/)).not.toBeNull();
|
||||
expect(screen.getByTitle(/claude-gone/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("blocks a profile whose fallback model is also gone (both-gone)", () => {
|
||||
it("keeps a profile selectable when both saved models are absent from the host probe", () => {
|
||||
const option = renderOptions([
|
||||
profileOption({ model: GONE_MODEL, fallback_model: "other-gone" }),
|
||||
]);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBe("true");
|
||||
expect(option.getAttribute("data-reason")).toContain(GONE_MODEL);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
|
||||
expect(screen.getByTitle(/claude-gone/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a gone-model profile with auto-fallback selectable without a warning", () => {
|
||||
it("keeps auto-fallback profiles selectable and shows the host probe hint", () => {
|
||||
const option = renderOptions([profileOption({ model: GONE_MODEL, auto_fallback: true })]);
|
||||
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
|
||||
expect(screen.queryByTitle(/claude-gone/)).toBeNull();
|
||||
expect(screen.getByTitle(/claude-gone/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not gate when the agent model list is unknown (probe not landed)", () => {
|
||||
|
||||
@@ -142,45 +142,21 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt
|
||||
const profileLabel = parts[1] ?? "";
|
||||
const isPassthrough = profile.cli_passthrough === true;
|
||||
const warning = getCapabilityWarning(profile.capability_status, profile.capability_error);
|
||||
// No-silent-model-fallback: a profile whose start model is no longer
|
||||
// advertised ("gone") is blocked from selection unless it opted into
|
||||
// an explicit fallback model (shown as a warning) or the legacy
|
||||
// auto-fallback toggle.
|
||||
//
|
||||
// The advertised list is the host-utility probe cache. While it has
|
||||
// not loaded yet (empty list) gone-ness is unknown and deliberately
|
||||
// NOT assumed: nothing is blocked and no warning is shown. The backend
|
||||
// rejects the launch if the model is genuinely unavailable, so this
|
||||
// startup window is cosmetic. A fallback model that is itself gone
|
||||
// does not make the profile selectable — it would promise a switch
|
||||
// SetModel cannot apply, so the profile is blocked like strict mode.
|
||||
// The host-utility probe is an editing hint only. The selected
|
||||
// executor owns the launch-time model catalog, so a host-only mismatch
|
||||
// must never remove a profile from the task selector.
|
||||
const advertised = advertisedModelIDs(availableAgents, profile.agent_name);
|
||||
const startModelGone = Boolean(
|
||||
profile.model && advertised.length > 0 && !advertised.includes(profile.model),
|
||||
);
|
||||
const fallbackGone = Boolean(
|
||||
profile.fallback_model &&
|
||||
advertised.length > 0 &&
|
||||
!advertised.includes(profile.fallback_model),
|
||||
);
|
||||
const autoFallbackOn = profile.auto_fallback === true;
|
||||
const fallbackUsable = Boolean(profile.fallback_model) && !fallbackGone;
|
||||
const blocked = startModelGone && !autoFallbackOn && !fallbackUsable;
|
||||
const fallbackWarning = startModelGone && !autoFallbackOn && fallbackUsable;
|
||||
const disabledReason = blocked
|
||||
? t("settings:profileStartModelUnavailable", { model: profile.model })
|
||||
: undefined;
|
||||
const fallbackNote = fallbackWarning
|
||||
? t("settings:profileFallbackWillBeUsed", {
|
||||
model: profile.model,
|
||||
fallback: profile.fallback_model,
|
||||
})
|
||||
const modelProbeNote = startModelGone
|
||||
? t("settings:profileStartModelNotAdvertisedOnHost", { model: profile.model })
|
||||
: undefined;
|
||||
return {
|
||||
value: profile.id,
|
||||
label: profile.label,
|
||||
disabled: blocked || undefined,
|
||||
disabledReason,
|
||||
disabled: undefined,
|
||||
disabledReason: undefined,
|
||||
renderLabel: () => (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex shrink-0 items-center justify-between gap-2">
|
||||
@@ -190,10 +166,10 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt
|
||||
{warning && (
|
||||
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
|
||||
)}
|
||||
{fallbackNote && (
|
||||
{modelProbeNote && (
|
||||
<IconAlertTriangle
|
||||
className="size-3.5 shrink-0 text-amber-500"
|
||||
title={fallbackNote}
|
||||
title={modelProbeNote}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
@@ -211,10 +187,10 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{fallbackNote && (
|
||||
{modelProbeNote && (
|
||||
<span className="flex items-center gap-1.5 pl-1 text-xs text-amber-600">
|
||||
<IconAlertTriangle className="size-3 shrink-0" aria-hidden />
|
||||
{fallbackNote}
|
||||
{modelProbeNote}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { StatusMessage } from "./status-message";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function modelSelectionWarningMessage(): Message {
|
||||
return {
|
||||
id: "status-1",
|
||||
session_id: toSessionId("session-1"),
|
||||
task_id: toTaskId("task-1"),
|
||||
author_type: "agent",
|
||||
content: "",
|
||||
type: "status",
|
||||
created_at: "2026-08-15T00:00:00Z",
|
||||
metadata: {
|
||||
variant: "warning",
|
||||
kind: "model_selection_warning",
|
||||
reason: "requested_not_advertised",
|
||||
requested_model: "claude-sonnet-4",
|
||||
effective_model: "claude-haiku-4",
|
||||
agent_id: "claude-acp",
|
||||
executor_type: "ssh",
|
||||
executor_profile_id: "executor-1",
|
||||
remediation: ["executor_credentials", "copied_agent_configuration", "agent_version"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("StatusMessage model selection warnings", () => {
|
||||
it("renders the persisted decision context and remediation guidance", () => {
|
||||
render(<StatusMessage comment={modelSelectionWarningMessage()} />);
|
||||
|
||||
expect(screen.getByText("The executor could not use the saved model selection.")).toBeTruthy();
|
||||
expect(screen.getByText("claude-sonnet-4")).toBeTruthy();
|
||||
expect(screen.getByText("claude-haiku-4")).toBeTruthy();
|
||||
expect(screen.getByText("claude-acp")).toBeTruthy();
|
||||
expect(screen.getByText("executor-1")).toBeTruthy();
|
||||
expect(screen.getByText("Check executor credentials.")).toBeTruthy();
|
||||
expect(screen.getByText("Check the copied agent configuration.")).toBeTruthy();
|
||||
expect(screen.getByText("Check the agent version in the executor.")).toBeTruthy();
|
||||
expect(screen.getByText("The saved model was not advertised by the executor.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,16 @@ interface ErrorMetadata extends StatusMetadata {
|
||||
stderr?: string[];
|
||||
provider?: string;
|
||||
provider_agent?: string;
|
||||
kind?: string;
|
||||
decision_id?: string;
|
||||
reason?: string;
|
||||
requested_model?: string;
|
||||
effective_model?: string;
|
||||
fallback_model?: string;
|
||||
agent_id?: string;
|
||||
executor_type?: string;
|
||||
executor_profile_id?: string;
|
||||
remediation?: string[];
|
||||
}
|
||||
|
||||
function getStatusStyle(isError: boolean, isWarning: boolean) {
|
||||
@@ -134,10 +144,103 @@ function parseStatusMetadata(comment: Message) {
|
||||
const progress =
|
||||
typeof metadata?.progress === "number" ? Math.min(Math.max(metadata.progress, 0), 100) : null;
|
||||
const statusLine = metadata?.stage || metadata?.status;
|
||||
const message = metadata?.message || comment.content || statusLine || t("task:statusUpdate");
|
||||
const isError = comment.type === "error" || metadata?.variant === "error";
|
||||
const isWarning = metadata?.variant === "warning" || metadata?.cancelled === true;
|
||||
return { metadata, progress, statusLine, message, isError, isWarning };
|
||||
return {
|
||||
metadata,
|
||||
progress,
|
||||
statusLine,
|
||||
message: getStatusMessage(comment, metadata, statusLine),
|
||||
isError: isErrorStatus(comment, metadata),
|
||||
isWarning: isWarningStatus(metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusMessage(
|
||||
comment: Message,
|
||||
metadata: ErrorMetadata | undefined,
|
||||
statusLine: string | undefined,
|
||||
): string {
|
||||
if (metadata?.kind === "model_selection_warning") return t("task:modelSelectionWarning");
|
||||
return metadata?.message || comment.content || statusLine || t("task:statusUpdate");
|
||||
}
|
||||
|
||||
function isErrorStatus(comment: Message, metadata: ErrorMetadata | undefined): boolean {
|
||||
return comment.type === "error" || metadata?.variant === "error";
|
||||
}
|
||||
|
||||
function isWarningStatus(metadata: ErrorMetadata | undefined): boolean {
|
||||
return metadata?.variant === "warning" || metadata?.cancelled === true;
|
||||
}
|
||||
|
||||
function modelSelectionReasonLabel(reason: string | undefined): string {
|
||||
const keyByReason: Record<string, string> = {
|
||||
requested_not_advertised: "task:modelSelectionReasonRequestedNotAdvertised",
|
||||
fallback_not_advertised: "task:modelSelectionReasonFallbackNotAdvertised",
|
||||
catalog_empty: "task:modelSelectionReasonCatalogEmpty",
|
||||
selection_unsupported: "task:modelSelectionReasonUnsupported",
|
||||
selection_failed_auto_fallback: "task:modelSelectionReasonAutoFallback",
|
||||
};
|
||||
return reason
|
||||
? t(keyByReason[reason] ?? "task:modelSelectionReasonUnknown", { reason })
|
||||
: t("task:unknown");
|
||||
}
|
||||
|
||||
function ModelSelectionWarningDetails({ metadata }: { metadata: ErrorMetadata }) {
|
||||
const requested = metadata.requested_model || t("task:unknown");
|
||||
const effective = metadata.effective_model || t("task:modelSelectionProviderDefault");
|
||||
const remediation = Array.isArray(metadata.remediation) ? metadata.remediation : [];
|
||||
const remediationKey: Record<string, string> = {
|
||||
executor_credentials: "task:modelSelectionRemediationCredentials",
|
||||
copied_agent_configuration: "task:modelSelectionRemediationConfiguration",
|
||||
agent_version: "task:modelSelectionRemediationAgentVersion",
|
||||
};
|
||||
return (
|
||||
<div className="mt-2 space-y-2 text-[11px] text-muted-foreground">
|
||||
<div className="grid gap-1">
|
||||
<p>
|
||||
{t("task:modelSelectionRequested")}: <code>{requested}</code>
|
||||
</p>
|
||||
<p>
|
||||
{t("task:modelSelectionEffective")}: <code>{effective}</code>
|
||||
</p>
|
||||
{metadata.fallback_model && (
|
||||
<p>
|
||||
{t("task:modelSelectionFallback")}: <code>{metadata.fallback_model}</code>
|
||||
</p>
|
||||
)}
|
||||
{metadata.agent_id && (
|
||||
<p>
|
||||
{t("task:modelSelectionAgent")}: <code>{metadata.agent_id}</code>
|
||||
</p>
|
||||
)}
|
||||
{metadata.executor_type && (
|
||||
<p>
|
||||
{t("task:modelSelectionExecutor")}: <code>{metadata.executor_type}</code>
|
||||
</p>
|
||||
)}
|
||||
{metadata.executor_profile_id && (
|
||||
<p>
|
||||
{t("task:modelSelectionExecutorProfile")}: <code>{metadata.executor_profile_id}</code>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{t("task:modelSelectionReason")}:{" "}
|
||||
<code>{modelSelectionReasonLabel(metadata.reason)}</code>
|
||||
</p>
|
||||
</div>
|
||||
{remediation.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">{t("task:modelSelectionRemediationTitle")}</p>
|
||||
<ul className="list-disc space-y-0.5 pl-4">
|
||||
{remediation.map((item) => (
|
||||
<li key={item}>
|
||||
{t(remediationKey[item] ?? "task:modelSelectionRemediationUnknown")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeExpandableContent(isError: boolean, metadata: ErrorMetadata | undefined) {
|
||||
@@ -203,6 +306,7 @@ type StatusMessageBodyProps = {
|
||||
hasExpandableContent: boolean;
|
||||
progress: number | null;
|
||||
statusLine: string | undefined;
|
||||
modelSelectionWarning?: ErrorMetadata;
|
||||
};
|
||||
|
||||
function StatusMessageBody({
|
||||
@@ -213,6 +317,7 @@ function StatusMessageBody({
|
||||
hasExpandableContent,
|
||||
progress,
|
||||
statusLine,
|
||||
modelSelectionWarning,
|
||||
}: StatusMessageBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -221,6 +326,7 @@ function StatusMessageBody({
|
||||
{message || t("task:anErrorOccurred")}
|
||||
</div>
|
||||
{isExpanded && hasExpandableContent && <ExpandableErrorDetails errorDetails={errorDetails} />}
|
||||
{modelSelectionWarning && <ModelSelectionWarningDetails metadata={modelSelectionWarning} />}
|
||||
{progress !== null && <StatusProgressBar progress={progress} statusLine={statusLine} />}
|
||||
</div>
|
||||
);
|
||||
@@ -262,6 +368,9 @@ export const StatusMessage = memo(function StatusMessage({ comment }: { comment:
|
||||
hasExpandableContent={hasExpandableContent}
|
||||
progress={progress}
|
||||
statusLine={statusLine}
|
||||
modelSelectionWarning={
|
||||
metadata?.kind === "model_selection_warning" ? metadata : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -943,6 +943,23 @@ export class ApiClient {
|
||||
return this.request("GET", "/api/v1/executors");
|
||||
}
|
||||
|
||||
async listAgentConfigBundles(): Promise<{
|
||||
bundles: Array<{
|
||||
id: string;
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
label: string;
|
||||
available: boolean;
|
||||
files: Array<{
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
available: boolean;
|
||||
}>;
|
||||
}>;
|
||||
}> {
|
||||
return this.request("GET", "/api/v1/agent-config-bundles");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inference-capable agents as the utility/review paths see them. The `id` here
|
||||
* is the registered agent-type id (e.g. "claude-acp"), which is what
|
||||
|
||||
@@ -7,6 +7,26 @@ import type { ApiClient } from "../../helpers/api-client";
|
||||
export const AGENT_REPLY = "The settled answer contains a useful detail.";
|
||||
export const SELECTED_REPLY_TEXT = "settled answer";
|
||||
|
||||
export async function waitForAgentSessionInput(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
const state = sessions.find((session) => session.id === sessionId)?.state;
|
||||
return state === "IDLE" || state === "WAITING_FOR_INPUT";
|
||||
},
|
||||
{
|
||||
timeout: 45_000,
|
||||
message: `agent session ${sessionId} never became ready for direct input`,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectAgentMessageHighlight(body: Locator, expectedCount: number) {
|
||||
const highlightName = await body.getAttribute("data-agent-message-highlight-name");
|
||||
if (!highlightName) throw new Error("Expected an agent message highlight name");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
openSeededQuickChatReply,
|
||||
SELECTED_REPLY_TEXT,
|
||||
selectAgentReplyText,
|
||||
waitForAgentSessionInput,
|
||||
} from "./agent-message-comments-helpers";
|
||||
|
||||
async function expectTouchTarget(locator: Locator) {
|
||||
@@ -46,9 +47,9 @@ test.describe("Agent message comments on mobile", () => {
|
||||
"Mobile Agent Message Comments",
|
||||
);
|
||||
|
||||
// Commenting only needs the seeded agent message. Do not wait for the
|
||||
// composer to become editable because the mock reply may be visible while
|
||||
// the session is still reporting a running status on mobile.
|
||||
// Run sends directly only when the session is ready for input. The seeded
|
||||
// reply can be visible before the backend reports that state on mobile.
|
||||
await waitForAgentSessionInput(apiClient, task.id, task.session_id!);
|
||||
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
|
||||
const commentTrigger = testPage.getByTestId("agent-message-comment-trigger");
|
||||
await expect(commentTrigger).toBeVisible();
|
||||
|
||||
@@ -123,18 +123,18 @@ async function touchDragTo(
|
||||
// announce a droppable for it. Dispatching DragMove before that resolves no
|
||||
// target and the drop silently becomes a no-op.
|
||||
await expect.poll(announcedTarget, { timeout: 5_000 }).toContain(MOVED_OVER);
|
||||
const initialAnnouncement = await announcedTarget();
|
||||
|
||||
// The second move is a 2px nudge that re-dispatches DragMove against the
|
||||
// same droppable, so it produces no NEW announcement to wait on: a second
|
||||
// `toContain(MOVED_OVER)` here would be satisfied by the first move's
|
||||
// announcement and assert nothing, and a `.not.toBe(previous)` would never
|
||||
// become true. The wait above already proves the drag is live and measured,
|
||||
// which is the precondition pointerup actually needs.
|
||||
// The first move can announce the dragged row itself because it also crosses
|
||||
// the sensor's activation distance. The next move must resolve the intended
|
||||
// row before pointerup; otherwise dnd-kit accepts a self-drop and the test
|
||||
// passes through the gesture without exercising reorder at all.
|
||||
await page.dispatchEvent("body", "pointermove", {
|
||||
...down,
|
||||
clientX: end.x,
|
||||
clientY: end.y + 2,
|
||||
});
|
||||
await expect.poll(announcedTarget, { timeout: 5_000 }).not.toBe(initialAnnouncement);
|
||||
|
||||
await page.dispatchEvent("body", "pointerup", {
|
||||
...down,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../../fixtures/docker-test-base";
|
||||
import { E2E_IMAGE_TAG } from "../../fixtures/docker-probe";
|
||||
import { dockerFileContent } from "../../helpers/docker";
|
||||
import { waitForLatestSessionDone, waitForSessionDone } from "../../helpers/session";
|
||||
|
||||
function dockerPathExists(containerId: string, filePath: string): boolean {
|
||||
return (
|
||||
spawnSync("docker", ["exec", containerId, "test", "-e", filePath], {
|
||||
stdio: "ignore",
|
||||
}).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Docker executor — portable agent configuration", () => {
|
||||
test("copies selected files on fresh provisioning and preserves them on warm resume", async ({
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const sourceDir = path.join(backend.tmpDir, ".mock-agent");
|
||||
const sourceFile = path.join(sourceDir, "settings.json");
|
||||
fs.mkdirSync(sourceDir, { recursive: true });
|
||||
fs.writeFileSync(sourceFile, '{"source":"first"}\n');
|
||||
fs.writeFileSync(path.join(sourceDir, "unselected.json"), "must not be copied\n");
|
||||
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const dockerExecutor = executors.find((executor) => executor.type === "local_docker");
|
||||
expect(dockerExecutor).toBeTruthy();
|
||||
const profile = await apiClient.createExecutorProfile(dockerExecutor!.id, {
|
||||
name: "E2E portable config Docker profile",
|
||||
config: {
|
||||
image_tag: E2E_IMAGE_TAG,
|
||||
agent_config_bundles: JSON.stringify(["mock.settings"]),
|
||||
},
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Docker portable agent configuration",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
executor_profile_id: profile.id,
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for fresh Docker task");
|
||||
|
||||
const fresh = await apiClient.getTaskEnvironment(task.id);
|
||||
expect(fresh?.container_id).toBeTruthy();
|
||||
expect(dockerFileContent(fresh!.container_id!, "/root/.mock-agent/settings.json")).toBe(
|
||||
'{"source":"first"}\n',
|
||||
);
|
||||
expect(dockerPathExists(fresh!.container_id!, "/root/.mock-agent/unselected.json")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
fs.writeFileSync(sourceFile, '{"source":"second"}\n');
|
||||
const warm = await apiClient.launchSession({
|
||||
task_id: task.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: profile.id,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "/e2e:simple-message",
|
||||
});
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
warm.session_id,
|
||||
"Waiting for warm Docker resume",
|
||||
);
|
||||
|
||||
const resumed = await apiClient.getTaskEnvironment(task.id);
|
||||
expect(resumed?.container_id).toBe(fresh?.container_id);
|
||||
expect(dockerFileContent(resumed!.container_id!, "/root/.mock-agent/settings.json")).toBe(
|
||||
'{"source":"first"}\n',
|
||||
);
|
||||
|
||||
const reset = await apiClient.rawRequest(
|
||||
"POST",
|
||||
`/api/v1/tasks/${task.id}/environment/reset`,
|
||||
{},
|
||||
);
|
||||
expect(reset.status).toBe(200);
|
||||
const relaunched = await apiClient.launchSession({
|
||||
task_id: task.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: profile.id,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "/e2e:simple-message",
|
||||
});
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
relaunched.session_id,
|
||||
"Waiting for reset Docker launch",
|
||||
);
|
||||
|
||||
const resetEnvironment = await apiClient.getTaskEnvironment(task.id);
|
||||
expect(resetEnvironment?.container_id).toBeTruthy();
|
||||
expect(resetEnvironment?.container_id).not.toBe(fresh?.container_id);
|
||||
expect(
|
||||
dockerFileContent(resetEnvironment!.container_id!, "/root/.mock-agent/settings.json"),
|
||||
).toBe('{"source":"second"}\n');
|
||||
expect(relaunched.session_id).toBeTruthy();
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SETTINGS_TAKEOVER_TESTID, setSettingsMenuMode } from "../../helpers/settings-menu";
|
||||
|
||||
function sidebarGitHubRow(testPage: Page) {
|
||||
const sidebar = testPage.getByTestId("app-sidebar");
|
||||
return sidebar.locator('a[href="/github"]:not([data-testid="integration-header-shortcut"])');
|
||||
}
|
||||
|
||||
async function expandIntegrationsSection(testPage: Page) {
|
||||
const sidebar = testPage.getByTestId("app-sidebar");
|
||||
const integrationsToggle = sidebar.getByRole("button", {
|
||||
name: "Integrations",
|
||||
exact: true,
|
||||
});
|
||||
await expect(integrationsToggle).toHaveCount(1, { timeout: 10_000 });
|
||||
if ((await integrationsToggle.getAttribute("aria-expanded")) !== "true") {
|
||||
await integrationsToggle.click();
|
||||
}
|
||||
await expect(integrationsToggle).toHaveAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
// Covers docs/specs/integrations/enable-disable-toggle.md's nav-visibility
|
||||
// scenarios: with "Hide disabled integrations from left panel navigation"
|
||||
// off (the default), a disabled-but-configured integration still shows in
|
||||
@@ -17,15 +36,20 @@ test.describe("hide disabled integrations from left panel navigation", () => {
|
||||
await apiClient.mockGitHubSetUser("test-user");
|
||||
|
||||
await testPage.goto("/settings/integrations");
|
||||
const hideDisabledSwitch = testPage.locator("#hide-disabled-integrations-in-nav");
|
||||
if ((await hideDisabledSwitch.getAttribute("aria-checked")) === "true") {
|
||||
await hideDisabledSwitch.click();
|
||||
}
|
||||
await expect(hideDisabledSwitch).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
const githubSwitch = testPage.locator("#github-enabled");
|
||||
if ((await githubSwitch.getAttribute("aria-checked")) === "false") {
|
||||
await githubSwitch.click();
|
||||
}
|
||||
await expect(githubSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await githubSwitch.click();
|
||||
await expect(githubSwitch).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
const hideDisabledSwitch = testPage.locator("#hide-disabled-integrations-in-nav");
|
||||
await expect(hideDisabledSwitch).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await testPage
|
||||
.getByTestId("settings-floating-save")
|
||||
.getByRole("button", { name: "Save changes" })
|
||||
@@ -35,7 +59,11 @@ test.describe("hide disabled integrations from left panel navigation", () => {
|
||||
// Leave Settings — the sidebar's Integrations section only renders
|
||||
// outside the Settings takeover.
|
||||
await testPage.goto("/tasks");
|
||||
const githubNavLink = testPage.getByRole("link", { name: "GitHub", exact: true });
|
||||
// The header also exposes an always-visible GitHub shortcut. Scope this
|
||||
// assertion to the actual sidebar row so it tests the setting's contract
|
||||
// instead of a separate shortcut surface.
|
||||
await expandIntegrationsSection(testPage);
|
||||
const githubNavLink = sidebarGitHubRow(testPage);
|
||||
await expect(githubNavLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The Settings left panel's per-workspace Integrations tree is also part
|
||||
@@ -71,7 +99,7 @@ test.describe("hide disabled integrations from left panel navigation", () => {
|
||||
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await expect(testPage.getByRole("link", { name: "GitHub", exact: true })).not.toBeVisible();
|
||||
await expect(sidebarGitHubRow(testPage)).toHaveCount(0);
|
||||
|
||||
// The Settings left-panel Integrations tree hides it as well.
|
||||
await testPage.goto(integrationsPath);
|
||||
@@ -89,8 +117,7 @@ test.describe("hide disabled integrations from left panel navigation", () => {
|
||||
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await expect(testPage.getByRole("link", { name: "GitHub", exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expandIntegrationsSection(testPage);
|
||||
await expect(sidebarGitHubRow(testPage)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,18 +85,19 @@ test.describe("GitHub workspace settings on mobile", () => {
|
||||
expect(drawerBox).not.toBeNull();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [currentScrollBox, currentFadeBox] = await Promise.all([
|
||||
scrollBody.boundingBox(),
|
||||
scrollFade.boundingBox(),
|
||||
]);
|
||||
if (!currentScrollBox || !currentFadeBox) return Number.POSITIVE_INFINITY;
|
||||
return Math.abs(
|
||||
currentFadeBox.y +
|
||||
currentFadeBox.height -
|
||||
(currentScrollBox.y + currentScrollBox.height),
|
||||
);
|
||||
},
|
||||
() =>
|
||||
testPage.evaluate(() => {
|
||||
const scrollElement = document.querySelector<HTMLElement>(
|
||||
'[data-testid="github-connection-scroll"]',
|
||||
);
|
||||
const fadeElement = document.querySelector<HTMLElement>(
|
||||
'[data-testid="github-connection-scroll-fade"]',
|
||||
);
|
||||
if (!scrollElement || !fadeElement) return Number.POSITIVE_INFINITY;
|
||||
const scrollBox = scrollElement.getBoundingClientRect();
|
||||
const fadeBox = fadeElement.getBoundingClientRect();
|
||||
return Math.abs(fadeBox.bottom - scrollBox.bottom);
|
||||
}),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
@@ -110,9 +111,6 @@ test.describe("GitHub workspace settings on mobile", () => {
|
||||
expect(fadeBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(initialSaveBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(fadeBox!.y + fadeBox!.height - (scrollBox!.y + scrollBox!.height)),
|
||||
).toBeLessThanOrEqual(2);
|
||||
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height);
|
||||
const methodGroup = drawer.getByRole("radiogroup", { name: "Connection method" });
|
||||
await expect(methodGroup.getByRole("radio").first()).toHaveAttribute("id", "github-method-cli");
|
||||
|
||||
@@ -146,7 +146,7 @@ test.describe("Bitbucket plugin contract", () => {
|
||||
// error must not disclose the fixture's credential value. Exercise this
|
||||
// first because entity-source menus dispose their selected source after a
|
||||
// successful submission.
|
||||
await testPage.getByText("Mock Fast", { exact: true }).click();
|
||||
await session.clickSessionChatTab();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
const editor = visibleEditor(session.activeChat());
|
||||
await editor.fill("");
|
||||
|
||||
@@ -216,7 +216,6 @@ test.describe("Plugins — composer capability", () => {
|
||||
await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
|
||||
|
||||
await composerAction.getByTestId("e2e-composer-submit").click();
|
||||
await expect(composerAction).toHaveAttribute("data-status", "submitted");
|
||||
|
||||
// The native creation handler ran, not a plugin-built request: the app
|
||||
// navigated to a real task and the dictated prompt reached the agent.
|
||||
|
||||
@@ -172,8 +172,12 @@ async function selectOnlyMissingPRFromAddPanel(
|
||||
await expect(testPage.getByTestId(openItemTestId)).toHaveCount(0);
|
||||
await expect(item).toBeVisible({ timeout: 2_000 });
|
||||
await item.click({ force: true });
|
||||
await expect(session.prDetailTab()).toHaveCount(2, { timeout: 2_000 });
|
||||
}).toPass({ timeout: 15_000, intervals: [100, 250, 500] });
|
||||
// Dockview adds the keyed panel asynchronously after the menu closes. On
|
||||
// a loaded CI shard that can take longer than the menu interaction itself.
|
||||
// Keep the bounded menu retry, but give the panel its full readiness window
|
||||
// before retrying the click.
|
||||
await expect(session.prDetailTab()).toHaveCount(2, { timeout: 10_000 });
|
||||
}).toPass({ timeout: 30_000, intervals: [100, 250, 500] });
|
||||
}
|
||||
|
||||
test.describe("Multi-PR CI popover", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test.describe("PR watcher merged cleanup", () => {
|
||||
@@ -216,7 +217,15 @@ test.describe("PR watcher merged cleanup", () => {
|
||||
// on the auto-started session.
|
||||
const { sessions } = await apiClient.listTaskSessions(prTask!.id);
|
||||
expect(sessions.length).toBeGreaterThan(0);
|
||||
await apiClient.addUserMessage(prTask!.id, sessions[0].id, "thanks, looks good");
|
||||
const session = sessions[0];
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
prTask!.id,
|
||||
session.id,
|
||||
"auto-started review session did not become ready for user input",
|
||||
45_000,
|
||||
);
|
||||
await apiClient.addUserMessage(prTask!.id, session.id, "thanks, looks good");
|
||||
|
||||
// Simulate PR merged
|
||||
await apiClient.mockGitHubAddPRs([
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||||
import { waitForSessionAgentctlReady } from "../../helpers/session-store";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { multiMessageScript, planScript } from "../../helpers/seed-session-messages";
|
||||
|
||||
@@ -26,10 +28,19 @@ export async function seedTask(
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
1,
|
||||
`wait for seeded search session ${task.id} to finish its initial turn`,
|
||||
60_000,
|
||||
);
|
||||
await page.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await session.waitForChatIdle({ timeout: 60_000 });
|
||||
if (!task.session_id) throw new Error("seedTask did not return a session_id");
|
||||
await waitForSessionAgentctlReady(page, task.session_id);
|
||||
return { session, taskId: task.id, sessionId: task.session_id! };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
|
||||
import { waitForSessionAgentctlReady } from "../../helpers/session-store";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { MODIFIER } from "./shared";
|
||||
|
||||
@@ -89,6 +90,7 @@ test("@search searches all task repositories and opens the selected match", asyn
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await waitForSessionAgentctlReady(testPage, task.session_id);
|
||||
|
||||
// The global content-search shortcut must win even when an editable chat
|
||||
// surface owns focus.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
createMismatchedProfile,
|
||||
readModelSelectionWarnings,
|
||||
UNADVERTISED_MODEL,
|
||||
} from "./model-mismatch-warning-helpers";
|
||||
|
||||
test.describe("model mismatch warning on mobile", () => {
|
||||
test("shows the persisted warning without horizontal overflow", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const profile = await createMismatchedProfile(apiClient, "Mobile executor warning profile");
|
||||
try {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Mobile executor model mismatch warning",
|
||||
profile.id,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
expect(task.session_id).toBeTruthy();
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
task.session_id!,
|
||||
"Waiting for mobile model mismatch task continuation",
|
||||
);
|
||||
await expect
|
||||
.poll(() => readModelSelectionWarnings(apiClient, task.session_id!), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toHaveLength(1);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(
|
||||
session.activeChat().getByText("The executor could not use the saved model selection."),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.activeChat()).toContainText(UNADVERTISED_MODEL);
|
||||
await expect(session.activeChat()).toContainText("Mock Fast");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch warning");
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(
|
||||
session.activeChat().getByText("The executor could not use the saved model selection."),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "reloaded mobile model mismatch warning");
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AgentProfile } from "../../../lib/types/http-agents";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
|
||||
export const UNADVERTISED_MODEL = "host-only-model";
|
||||
|
||||
export async function createMismatchedProfile(
|
||||
apiClient: ApiClient,
|
||||
name: string,
|
||||
): Promise<AgentProfile> {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0];
|
||||
if (!agent) throw new Error("The E2E fixture must provide a mock agent");
|
||||
return apiClient.createAgentProfile(agent.id, name, { model: UNADVERTISED_MODEL });
|
||||
}
|
||||
|
||||
export async function readModelSelectionWarnings(
|
||||
apiClient: ApiClient,
|
||||
sessionId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>
|
||||
> {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
return messages.filter((message) => message.metadata?.kind === "model_selection_warning");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
createMismatchedProfile,
|
||||
readModelSelectionWarnings,
|
||||
UNADVERTISED_MODEL,
|
||||
} from "./model-mismatch-warning-helpers";
|
||||
|
||||
test.describe("model mismatch warning", () => {
|
||||
test("continues with the executor default and persists one warning after reload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const profile = await createMismatchedProfile(apiClient, "Executor default warning profile");
|
||||
try {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Executor model mismatch warning",
|
||||
profile.id,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
expect(task.session_id).toBeTruthy();
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
task.session_id!,
|
||||
"Waiting for model mismatch task continuation",
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => readModelSelectionWarnings(apiClient, task.session_id!), {
|
||||
timeout: 15_000,
|
||||
message: "Waiting for the persisted model-selection warning",
|
||||
})
|
||||
.toHaveLength(1);
|
||||
const warnings = await readModelSelectionWarnings(apiClient, task.session_id!);
|
||||
expect(warnings[0]?.content).toContain("saved model selection");
|
||||
expect(warnings[0]?.metadata).toMatchObject({
|
||||
kind: "model_selection_warning",
|
||||
reason: "requested_not_advertised",
|
||||
requested_model: UNADVERTISED_MODEL,
|
||||
agent_id: "mock-agent",
|
||||
});
|
||||
expect(warnings[0]?.metadata?.effective_model).toBe("mock-fast");
|
||||
expect((await apiClient.getAgentProfile(profile.id)).model).toBe(UNADVERTISED_MODEL);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(
|
||||
session.activeChat().getByText("The executor could not use the saved model selection."),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.activeChat()).toContainText(UNADVERTISED_MODEL);
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(
|
||||
session.activeChat().getByText("The executor could not use the saved model selection."),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.activeChat()).toContainText("Mock Fast");
|
||||
await expect
|
||||
.poll(() => readModelSelectionWarnings(apiClient, task.session_id!))
|
||||
.toHaveLength(1);
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ async function seedLocalSession(
|
||||
// See seedRemoteSession: waitForChatIdle handles the WS-subscribe race that
|
||||
// makes a raw idleInput wait flake when the auto-started agent finishes early.
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await waitForSessionAgentctlReady(testPage, task.session_id);
|
||||
|
||||
return { session, sessionId: task.session_id };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export const PORTABLE_CONFIG_BUNDLE_ID = "mock.settings";
|
||||
export const MOCK_CODEX_CONFIG_BUNDLE_ID = "codex-acp.settings";
|
||||
export const DEFAULT_CONFIG_AGENT_ID = "mock-agent";
|
||||
|
||||
export function portableConfigSection(page: Page, agentId = DEFAULT_CONFIG_AGENT_ID): Locator {
|
||||
return page.getByTestId(`agent-config-options-${agentId}`);
|
||||
}
|
||||
|
||||
export function portableConfigInfo(page: Page, agentId = DEFAULT_CONFIG_AGENT_ID): Locator {
|
||||
return page.getByTestId(`agent-config-info-${agentId}`);
|
||||
}
|
||||
|
||||
export async function selectPortableConfigBundle(
|
||||
page: Page,
|
||||
bundleId = PORTABLE_CONFIG_BUNDLE_ID,
|
||||
agentId = DEFAULT_CONFIG_AGENT_ID,
|
||||
): Promise<void> {
|
||||
const row = portableConfigSection(page, agentId).getByTestId(
|
||||
`portable-config-bundle-${bundleId}`,
|
||||
);
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("checkbox").check();
|
||||
}
|
||||
|
||||
export function selectedBundleIds(config: Record<string, string> | undefined): string[] {
|
||||
const raw = config?.agent_config_bundles;
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
}
|
||||
|
||||
export async function expectTouchTarget(locator: Locator): Promise<void> {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
export async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBe(false);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
MOCK_CODEX_CONFIG_BUNDLE_ID,
|
||||
portableConfigInfo,
|
||||
portableConfigSection,
|
||||
selectedBundleIds,
|
||||
selectPortableConfigBundle,
|
||||
} from "./executor-agent-config-helpers";
|
||||
|
||||
test.describe("portable agent configuration settings", () => {
|
||||
test("saves configuration independently from authentication and reloads it", async ({
|
||||
apiClient,
|
||||
backend,
|
||||
testPage,
|
||||
}) => {
|
||||
// The baseline E2E agent has no credentials by design. Add the mock Codex
|
||||
// alias for this settings-only scenario so the auth checkbox remains a real,
|
||||
// independently selectable control without requiring provider secrets.
|
||||
await backend.restart({ KANDEV_MOCK_PROVIDERS: "codex-acp" });
|
||||
const configDir = path.join(backend.tmpDir, ".mock-agent");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "settings.json"), '{"source":"settings"}\n');
|
||||
const catalog = await apiClient.listAgentConfigBundles();
|
||||
expect(
|
||||
catalog.bundles.some(
|
||||
(bundle) => bundle.id === MOCK_CODEX_CONFIG_BUNDLE_ID && bundle.agent_id === "codex-acp",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const executor = await apiClient.createExecutor("E2E portable config executor", "local_docker");
|
||||
const profile = await apiClient.createExecutorProfile(executor.id, {
|
||||
name: "E2E portable config profile",
|
||||
config: {},
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await testPage.goto(`/settings/executors/${profile.id}`);
|
||||
|
||||
const agentTrigger = testPage.getByRole("button", { name: /Mock Codex/ });
|
||||
await expect(agentTrigger).toBeVisible();
|
||||
await agentTrigger.click();
|
||||
|
||||
const section = portableConfigSection(testPage, "codex-acp");
|
||||
await expect(section).toBeVisible();
|
||||
const info = portableConfigInfo(testPage, "codex-acp");
|
||||
await expect(info).toBeVisible();
|
||||
await info.hover();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText("without changes");
|
||||
|
||||
await selectPortableConfigBundle(testPage, MOCK_CODEX_CONFIG_BUNDLE_ID, "codex-acp");
|
||||
await expect(
|
||||
section
|
||||
.getByTestId(`portable-config-bundle-${MOCK_CODEX_CONFIG_BUNDLE_ID}`)
|
||||
.getByRole("checkbox"),
|
||||
).toBeChecked();
|
||||
|
||||
await testPage.getByRole("button", { name: "Mock Codex Not Configured" }).click();
|
||||
const authChoice = testPage.getByRole("checkbox", { name: "Copy auth files" }).first();
|
||||
await expect(authChoice).toBeVisible();
|
||||
await authChoice.click();
|
||||
await expect(authChoice).toBeChecked();
|
||||
|
||||
const saveButton = testPage
|
||||
.getByTestId("settings-floating-save")
|
||||
.getByRole("button", { name: "Save changes" });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(testPage.getByText("Profile saved")).toBeVisible();
|
||||
|
||||
const saved = await apiClient.getExecutorProfile(executor.id, profile.id);
|
||||
expect(selectedBundleIds(saved.config)).toEqual([MOCK_CODEX_CONFIG_BUNDLE_ID]);
|
||||
expect(JSON.parse(saved.config?.remote_credentials ?? "[]").length).toBeGreaterThan(0);
|
||||
|
||||
await testPage.reload();
|
||||
await testPage.getByRole("button", { name: /Mock Codex/ }).click();
|
||||
await expect(section).toBeVisible();
|
||||
await expect(
|
||||
section
|
||||
.getByTestId(`portable-config-bundle-${MOCK_CODEX_CONFIG_BUNDLE_ID}`)
|
||||
.getByRole("checkbox"),
|
||||
).toBeChecked();
|
||||
} finally {
|
||||
await apiClient.deleteExecutor(executor.id).catch(() => {});
|
||||
await backend.restart();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
expectTouchTarget,
|
||||
PORTABLE_CONFIG_BUNDLE_ID,
|
||||
portableConfigInfo,
|
||||
portableConfigSection,
|
||||
selectPortableConfigBundle,
|
||||
} from "./executor-agent-config-helpers";
|
||||
|
||||
test.describe("portable agent configuration settings on mobile", () => {
|
||||
test("uses a bottom drawer and keeps bundle controls reachable", async ({
|
||||
apiClient,
|
||||
backend,
|
||||
testPage,
|
||||
}) => {
|
||||
const configDir = path.join(backend.tmpDir, ".mock-agent");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "settings.json"), '{"source":"settings"}\n');
|
||||
const executor = await apiClient.createExecutor("E2E mobile portable config", "local_docker");
|
||||
const profile = await apiClient.createExecutorProfile(executor.id, {
|
||||
name: "E2E mobile portable config profile",
|
||||
config: {},
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await testPage.goto(`/settings/executors/${profile.id}`);
|
||||
const agentTrigger = testPage.getByRole("button", { name: /Mock Not Configured/ });
|
||||
await expect(agentTrigger).toBeVisible();
|
||||
await agentTrigger.tap();
|
||||
|
||||
const section = portableConfigSection(testPage);
|
||||
await expect(section).toBeVisible();
|
||||
|
||||
const info = portableConfigInfo(testPage);
|
||||
await expectTouchTarget(info);
|
||||
await info.tap();
|
||||
const drawer = testPage.getByRole("dialog");
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer).toContainText("Warm resumes keep the existing environment.");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(drawer).toBeHidden();
|
||||
|
||||
const row = section.getByTestId(`portable-config-bundle-${PORTABLE_CONFIG_BUNDLE_ID}`);
|
||||
await expectTouchTarget(row);
|
||||
await selectPortableConfigBundle(testPage);
|
||||
await expectNoHorizontalOverflow(testPage);
|
||||
} finally {
|
||||
await apiClient.deleteExecutor(executor.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,134 +1,35 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import {
|
||||
createMismatchedProfile,
|
||||
UNADVERTISED_MODEL,
|
||||
} from "../session/model-mismatch-warning-helpers";
|
||||
|
||||
// Mobile companion to no-silent-model-fallback.spec.ts: the task-create
|
||||
// picker's fallback explanation must be discoverable on touch — visible
|
||||
// secondary text, not a hover-only tooltip (icon title).
|
||||
const GONE_MODEL = "claude-gone";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("No silent model fallback on mobile", () => {
|
||||
test("profile fallback settings stack and open help in a touch drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("no agents available");
|
||||
const profile = await apiClient.createAgentProfile(agent.id, "Mobile Fallback Settings", {
|
||||
model: "mock-fast",
|
||||
fallback_model: "mock-smart",
|
||||
});
|
||||
|
||||
test.describe("executor-authoritative model selection on mobile", () => {
|
||||
test("keeps the host-mismatched profile reachable by touch", async ({ testPage, apiClient }) => {
|
||||
const profile = await createMismatchedProfile(apiClient, "Mobile host mismatch profile");
|
||||
try {
|
||||
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
|
||||
|
||||
const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger");
|
||||
await expect(fallbackTrigger).toBeVisible({ timeout: 15_000 });
|
||||
await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
|
||||
"Explicit fallback: mock-smart",
|
||||
);
|
||||
|
||||
await fallbackTrigger.tap();
|
||||
const autoOption = testPage.getByTestId("profile-auto-fallback-option");
|
||||
const explicitOption = testPage.getByTestId("profile-explicit-fallback-option");
|
||||
await expect(autoOption).toBeVisible();
|
||||
await expect(explicitOption).toBeVisible();
|
||||
|
||||
const autoBox = await autoOption.boundingBox();
|
||||
const explicitBox = await explicitOption.boundingBox();
|
||||
expect(autoBox).not.toBeNull();
|
||||
expect(explicitBox).not.toBeNull();
|
||||
if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out");
|
||||
expect(explicitBox.y).toBeGreaterThan(autoBox.y + 8);
|
||||
|
||||
const helpButton = testPage.getByTestId("profile-automatic-fallback-help");
|
||||
const helpBox = await helpButton.boundingBox();
|
||||
expect(helpBox).not.toBeNull();
|
||||
if (!helpBox) throw new Error("automatic fallback help button is not laid out");
|
||||
expect(helpBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(helpBox.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
// The disclosure finishes a height transition when its children mount;
|
||||
// keep the interaction touch-native while bypassing that transient
|
||||
// stability window.
|
||||
await helpButton.tap({ force: true });
|
||||
const helpDrawer = testPage.getByRole("dialog");
|
||||
await expect(helpDrawer).toContainText("Automatic fallback");
|
||||
await expect(helpDrawer).toContainText(/remaining advertised models/);
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(helpDrawer).toBeHidden();
|
||||
|
||||
const advancedOptions = testPage.getByTestId("profile-advanced-options");
|
||||
const advancedTrigger = testPage.getByTestId("profile-advanced-options-trigger");
|
||||
await expect(advancedTrigger).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByTestId("command-prefix-input")).toBeHidden();
|
||||
const fallbackBox = await testPage.getByTestId("profile-fallback-settings").boundingBox();
|
||||
const advancedBox = await advancedOptions.boundingBox();
|
||||
expect(fallbackBox).not.toBeNull();
|
||||
expect(advancedBox).not.toBeNull();
|
||||
if (!fallbackBox || !advancedBox) throw new Error("profile disclosures are not laid out");
|
||||
expect(advancedBox.y - (fallbackBox.y + fallbackBox.height)).toBeLessThanOrEqual(8);
|
||||
|
||||
await advancedTrigger.tap();
|
||||
await expect(testPage.getByTestId("command-prefix-input")).toBeVisible();
|
||||
|
||||
const overflow = await testPage.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(profile.id, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("task-create picker shows the fallback note as visible text on touch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("no agents available");
|
||||
const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback Mobile", {
|
||||
model: GONE_MODEL,
|
||||
fallback_model: "mock-fast",
|
||||
});
|
||||
|
||||
try {
|
||||
// Mobile kanban opens the create-task dialog from the floating action
|
||||
// button, not the desktop sidebar button.
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const fab = testPage.getByTestId("mobile-fab");
|
||||
await expect(fab).toBeVisible({ timeout: 15_000 });
|
||||
await fab.tap();
|
||||
await testPage.reload({ waitUntil: "networkidle" });
|
||||
await testPage.getByRole("button", { name: "Add task" }).tap();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("task-title-input").fill("Gone model picker mobile test");
|
||||
await testPage.getByTestId("task-description-input").fill("verify visible fallback note");
|
||||
|
||||
const selector = dialog.getByTestId("agent-profile-selector");
|
||||
await expect(selector).toBeVisible();
|
||||
await selector.tap();
|
||||
|
||||
const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback Mobile/ });
|
||||
await expect(fallbackOption).toBeVisible({ timeout: 15_000 });
|
||||
await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// The fallback explanation must be readable without hover: the
|
||||
// picker renders it as visible secondary text inside the option.
|
||||
await expect(fallbackOption).toContainText(/mock-fast/);
|
||||
await expect(fallbackOption).toContainText(/no longer available/);
|
||||
const option = testPage
|
||||
.getByRole("listbox")
|
||||
.getByRole("option", { name: profile.name, exact: false });
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option).toBeEnabled();
|
||||
await expect(dialog).toContainText(UNADVERTISED_MODEL);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch selector");
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(fallbackProfile.id, true);
|
||||
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,210 +1,35 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import {
|
||||
createMismatchedProfile,
|
||||
UNADVERTISED_MODEL,
|
||||
} from "../session/model-mismatch-warning-helpers";
|
||||
|
||||
// The mock agent advertises `mock-fast` / `mock-smart`; any other model id
|
||||
// is "gone" (configured but no longer advertised) — the scenario this
|
||||
// feature makes explicit instead of silently falling back.
|
||||
const GONE_MODEL = "claude-gone";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("No silent model fallback", () => {
|
||||
test("profile editor explains and persists the fallback settings disclosure", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("no agents available");
|
||||
const profile = await apiClient.createAgentProfile(agent.id, "Gone Start Model", {
|
||||
model: GONE_MODEL,
|
||||
});
|
||||
|
||||
try {
|
||||
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
|
||||
|
||||
// The start-model trigger keeps the gone model visible and red.
|
||||
const trigger = testPage.getByRole("button", { name: "Profile start model settings" });
|
||||
await expect(trigger).toBeVisible({ timeout: 15_000 });
|
||||
await expect(trigger).toContainText(GONE_MODEL);
|
||||
await expect(trigger).toHaveClass(/text-destructive/);
|
||||
|
||||
const capabilitiesRow = testPage.getByTestId("profile-capabilities-model-row");
|
||||
const modelOptionsError = testPage.getByTestId("model-config-resolution-error");
|
||||
const refreshCapabilities = testPage.getByTestId("profile-refresh-capabilities");
|
||||
await expect(modelOptionsError).toBeVisible({ timeout: 15_000 });
|
||||
const capabilitiesBox = await capabilitiesRow.boundingBox();
|
||||
const modelOptionsErrorBox = await modelOptionsError.boundingBox();
|
||||
const refreshBox = await refreshCapabilities.boundingBox();
|
||||
expect(capabilitiesBox).not.toBeNull();
|
||||
expect(modelOptionsErrorBox).not.toBeNull();
|
||||
expect(refreshBox).not.toBeNull();
|
||||
if (!capabilitiesBox || !modelOptionsErrorBox || !refreshBox) {
|
||||
throw new Error("model capability controls are not laid out");
|
||||
}
|
||||
expect(modelOptionsErrorBox.y).toBeGreaterThanOrEqual(
|
||||
capabilitiesBox.y + capabilitiesBox.height - 1,
|
||||
);
|
||||
expect(refreshBox.y).toBeLessThan(capabilitiesBox.y + capabilitiesBox.height);
|
||||
|
||||
const fallbackSettings = testPage.getByTestId("profile-fallback-settings");
|
||||
const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger");
|
||||
await expect(fallbackTrigger).toBeVisible({ timeout: 10_000 });
|
||||
await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
|
||||
"No fallback configured",
|
||||
);
|
||||
|
||||
// The fallback options are intentionally hidden until the user opens
|
||||
// the disclosure.
|
||||
await expect(testPage.getByTestId("profile-fallback-model-field")).toBeHidden();
|
||||
await fallbackTrigger.click();
|
||||
await expect(fallbackSettings).toHaveAttribute("data-state", "open");
|
||||
|
||||
const fallbackField = testPage.getByTestId("profile-fallback-model-field");
|
||||
await expect(fallbackField).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Desktop keeps the two options on one row.
|
||||
const autoOption = testPage.getByTestId("profile-auto-fallback-option");
|
||||
const explicitOption = testPage.getByTestId("profile-explicit-fallback-option");
|
||||
const autoBox = await autoOption.boundingBox();
|
||||
const explicitBox = await explicitOption.boundingBox();
|
||||
expect(autoBox).not.toBeNull();
|
||||
expect(explicitBox).not.toBeNull();
|
||||
if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out");
|
||||
expect(Math.abs(autoBox.y - explicitBox.y)).toBeLessThan(8);
|
||||
expect(explicitBox.x).toBeGreaterThan(autoBox.x);
|
||||
|
||||
// Fine-pointer users can focus or hover the info buttons for the
|
||||
// detailed explanation.
|
||||
const automaticHelp = testPage.getByTestId("profile-automatic-fallback-help");
|
||||
await automaticHelp.focus();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText(/remaining advertised models/);
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
// The fallback model is optional: without a value its selector stays
|
||||
// hidden behind the attached switch, and opting in reveals it.
|
||||
const fallbackSelector = testPage.getByRole("button", {
|
||||
name: "Agent fallback model settings",
|
||||
});
|
||||
await expect(fallbackSelector).toBeHidden({ timeout: 10_000 });
|
||||
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
|
||||
await expect(fallbackSelector).toBeVisible({ timeout: 10_000 });
|
||||
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
|
||||
await expect(fallbackSelector).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// Enabling automatic fallback keeps the explicit value visible but
|
||||
// disables its controls so it cannot be edited accidentally.
|
||||
await fallbackField.getByRole("switch", { name: "Agent fallback" }).click();
|
||||
await fallbackSelector.click();
|
||||
await testPage.getByRole("option", { name: /Mock Fast/ }).click();
|
||||
await expect(fallbackSelector).toContainText("Mock Fast");
|
||||
const toggle = testPage.getByTestId("profile-auto-fallback-field").getByRole("switch");
|
||||
await toggle.click();
|
||||
await expect(fallbackField).toBeVisible({ timeout: 10_000 });
|
||||
await expect(fallbackField.getByRole("switch", { name: "Agent fallback" })).toBeDisabled();
|
||||
await expect(
|
||||
fallbackField.getByRole("button", { name: "Agent fallback model settings" }),
|
||||
).toBeDisabled();
|
||||
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
|
||||
"Automatic fallback enabled",
|
||||
);
|
||||
|
||||
// Less frequently used launch settings follow the fallback disclosure.
|
||||
// Keep the command prefix out of the default card height until requested.
|
||||
const advancedOptions = testPage.getByTestId("profile-advanced-options");
|
||||
const advancedTrigger = testPage.getByTestId("profile-advanced-options-trigger");
|
||||
await expect(advancedTrigger).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByTestId("command-prefix-input")).toBeHidden();
|
||||
const fallbackBox = await fallbackSettings.boundingBox();
|
||||
const advancedBox = await advancedOptions.boundingBox();
|
||||
expect(fallbackBox).not.toBeNull();
|
||||
expect(advancedBox).not.toBeNull();
|
||||
if (!fallbackBox || !advancedBox) throw new Error("profile disclosures are not laid out");
|
||||
expect(advancedBox.y - (fallbackBox.y + fallbackBox.height)).toBeLessThanOrEqual(8);
|
||||
|
||||
await advancedTrigger.click();
|
||||
await expect(testPage.getByTestId("command-prefix-input")).toBeVisible();
|
||||
|
||||
// The gone model survives a save (no auto-heal on save).
|
||||
const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first();
|
||||
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
|
||||
await saveButton.click();
|
||||
await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 });
|
||||
|
||||
const savedProfile = await apiClient.getAgentProfile(profile.id);
|
||||
expect(savedProfile.fallbackModel).toBe("mock-fast");
|
||||
expect(savedProfile.autoFallback).toBe(true);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(testPage.getByTestId("profile-fallback-settings-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText(
|
||||
"Automatic fallback enabled",
|
||||
);
|
||||
await testPage.getByTestId("profile-fallback-settings-trigger").click();
|
||||
await expect(
|
||||
testPage
|
||||
.getByTestId("profile-fallback-model-field")
|
||||
.getByRole("button", { name: "Agent fallback model settings" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(profile.id, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("task-create picker blocks a strict-gone profile and warns for a fallback profile", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("no agents available");
|
||||
const strictProfile = await apiClient.createAgentProfile(agent.id, "Gone Strict", {
|
||||
model: GONE_MODEL,
|
||||
});
|
||||
const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback", {
|
||||
model: GONE_MODEL,
|
||||
fallback_model: "mock-fast",
|
||||
});
|
||||
|
||||
test.describe("executor-authoritative model selection", () => {
|
||||
test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient }) => {
|
||||
const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile");
|
||||
try {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await testPage.reload({ waitUntil: "networkidle" });
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("task-title-input").fill("Gone model picker test");
|
||||
await testPage.getByTestId("task-description-input").fill("verify gating");
|
||||
|
||||
// Open the agent profile dropdown.
|
||||
const selector = dialog.getByTestId("agent-profile-selector");
|
||||
await expect(selector).toBeVisible();
|
||||
await selector.click();
|
||||
|
||||
// Strict-gone profile: greyed out and unselectable.
|
||||
const strictOption = testPage.getByRole("option", { name: /Gone Strict/ });
|
||||
await expect(strictOption).toBeVisible({ timeout: 15_000 });
|
||||
await expect(strictOption).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// Fallback profile: selectable and carries the "fallback will be used"
|
||||
// warning as visible secondary text (touch-discoverable, not just a
|
||||
// hover tooltip) plus the alert icon.
|
||||
const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback/ });
|
||||
await expect(fallbackOption).toBeVisible({ timeout: 15_000 });
|
||||
await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
await expect(fallbackOption.getByRole("img", { name: /mock-fast/ })).toBeVisible();
|
||||
await expect(fallbackOption).toContainText(/no longer available/);
|
||||
const option = testPage
|
||||
.getByRole("listbox")
|
||||
.getByRole("option", { name: profile.name, exact: false });
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option).toBeEnabled();
|
||||
await expect(dialog).toContainText(
|
||||
`The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`,
|
||||
);
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(strictProfile.id, true);
|
||||
await apiClient.deleteAgentProfile(fallbackProfile.id, true);
|
||||
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,7 +131,10 @@ test.describe("create task selector scroll", () => {
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("repo-chip-trigger").first().click();
|
||||
await testPage.getByRole("option", { name: /E2E Repo/ }).click();
|
||||
// Other tests may leave repositories whose names start with "E2E Repo".
|
||||
// The seeded repository's accessible name continues with its path, so
|
||||
// anchor the match at the repository name and path separator.
|
||||
await testPage.getByRole("option", { name: /^E2E Repo \// }).click();
|
||||
|
||||
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
|
||||
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
|
||||
|
||||
@@ -69,10 +69,12 @@ test.describe("Task List", () => {
|
||||
hasText: "Hierarchy Child Task",
|
||||
});
|
||||
|
||||
// Wait for the filtered list to settle before reading row attributes. SSR
|
||||
// and the client list can briefly overlap while the search result mounts.
|
||||
await expect(parentRow).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(childRow).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(parentRow).toHaveAttribute("data-level", "0", { timeout: 5000 });
|
||||
await expect(childRow).toHaveAttribute("data-level", "1", { timeout: 5000 });
|
||||
await expect(parentRow).toHaveCount(1);
|
||||
await expect(childRow).toHaveCount(1);
|
||||
await expect(taskList.getByTestId("tasks-list-row-title")).toHaveText([
|
||||
"Hierarchy Parent Task",
|
||||
"Hierarchy Child Task",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForSessionState } from "../../helpers/session";
|
||||
import { waitForSessionAgentctlReady } from "../../helpers/session-store";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { errors, type Page } from "@playwright/test";
|
||||
@@ -280,6 +281,10 @@ test.describe("Terminal agent (TUI passthrough)", () => {
|
||||
message: "the initial passthrough turn did not settle before the cascade",
|
||||
timeout: 30_000,
|
||||
});
|
||||
// The API state can settle before the page has subscribed to the session's
|
||||
// agentctl lifecycle. Establish that subscription before moving the task,
|
||||
// so reset/relaunch events cannot race the terminal's initial handshake.
|
||||
await waitForSessionAgentctlReady(testPage, task.session_id as string);
|
||||
|
||||
// Trigger cascade: Analyze → (turn complete) → Implement (reset + auto_start) → Review
|
||||
await apiClient.moveTask(task.id, workflow.id, analyzeStep.id);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||||
import { waitForSessionAgentctlReady } from "../../helpers/session-store";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -27,6 +29,13 @@ async function seedTaskWithSession(
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
1,
|
||||
`wait for terminal keyboard session ${task.id} to finish its initial turn`,
|
||||
60_000,
|
||||
);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
@@ -34,20 +43,23 @@ async function seedTaskWithSession(
|
||||
// waitForChatIdle (not a raw idleInput wait) rides out the WS-subscribe race:
|
||||
// the mock agent can settle RUNNING->WAITING_FOR_INPUT before the client's WS
|
||||
// subscription registers, so the idle placeholder never renders without a reload.
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
await session.waitForChatIdle({ timeout: 60_000 });
|
||||
if (!task.session_id) throw new Error("seedTaskWithSession did not return a session_id");
|
||||
await waitForSessionAgentctlReady(testPage, task.session_id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Focus the terminal and wait for the shell to be ready (prompt visible). */
|
||||
async function focusTerminal(page: Page, session: SessionPage): Promise<void> {
|
||||
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
|
||||
await session.expectTerminalConnected(60_000);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const buf = await readTerminalBuffer(page);
|
||||
return buf.length > 0;
|
||||
},
|
||||
{ timeout: 15_000, message: "Waiting for terminal shell to connect" },
|
||||
{ timeout: 60_000, message: "Waiting for terminal shell to connect" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
|
||||
@@ -57,6 +57,36 @@ describe("useWorkspaceContentSearch request lifecycle", () => {
|
||||
expect(result.current.isSearching).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes matches before retry polling completes", async () => {
|
||||
const results = [
|
||||
{
|
||||
repository_name: "web",
|
||||
path: "src/cached-content-target.ts",
|
||||
line: 180,
|
||||
column: 1,
|
||||
preview: "cached marker",
|
||||
match_ranges: [{ start: 0, end: 13 }],
|
||||
},
|
||||
];
|
||||
mockSearchWorkspaceContent.mockResolvedValue({ results });
|
||||
const { result } = renderHook(() =>
|
||||
useWorkspaceContentSearch({
|
||||
enabled: true,
|
||||
query: "cached marker",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(250));
|
||||
|
||||
expect(mockSearchWorkspaceContent).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.results).toEqual(results);
|
||||
expect(result.current.isSearching).toBe(true);
|
||||
|
||||
await act(async () => vi.runAllTimersAsync());
|
||||
expect(result.current.isSearching).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the previous query results while the next search is pending", async () => {
|
||||
const previousResults = [
|
||||
{
|
||||
@@ -81,7 +111,9 @@ describe("useWorkspaceContentSearch request lifecycle", () => {
|
||||
expect(result.current.results).toEqual([]);
|
||||
expect(result.current.isSearching).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceContentSearch retry publishing", () => {
|
||||
it("publishes the first available results while later repositories are still retrying", async () => {
|
||||
const primaryResult = {
|
||||
repository_name: "primary",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
fetchJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchJson } from "../client";
|
||||
import { listAgentConfigBundles } from "./agent-config-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ bundles: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("agent configuration API", () => {
|
||||
it("does not reuse a stale availability response", async () => {
|
||||
await listAgentConfigBundles();
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith("/api/v1/agent-config-bundles", {
|
||||
cache: "no-store",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
|
||||
export type AgentConfigBundleFile = {
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type AgentConfigBundle = {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
label: string;
|
||||
files: AgentConfigBundleFile[];
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type ListAgentConfigBundlesResponse = {
|
||||
bundles: AgentConfigBundle[];
|
||||
};
|
||||
|
||||
export async function listAgentConfigBundles(
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<ListAgentConfigBundlesResponse> {
|
||||
return fetchJson<ListAgentConfigBundlesResponse>("/api/v1/agent-config-bundles", {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AgentCapabilitiesPayload,
|
||||
SessionInfoPayload,
|
||||
SessionModelsPayload,
|
||||
SessionModelSelectionWarningPayload,
|
||||
SessionMCPStatusPayload,
|
||||
SessionPromptUsagePayload,
|
||||
SessionTodosPayload,
|
||||
@@ -237,6 +238,10 @@ export type SessionBackendMessageMap = {
|
||||
timestamp: string;
|
||||
}
|
||||
>;
|
||||
"session.model_selection_warning": BackendMessage<
|
||||
"session.model_selection_warning",
|
||||
SessionModelSelectionWarningPayload
|
||||
>;
|
||||
"session.mcp_status_updated": BackendMessage<
|
||||
"session.mcp_status_updated",
|
||||
SessionMCPStatusPayload
|
||||
|
||||
@@ -50,6 +50,24 @@ export type SessionModelsPayload = {
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type SessionModelSelectionWarningPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
agent_id: string;
|
||||
warning: {
|
||||
kind: string;
|
||||
decision_id: string;
|
||||
reason: string;
|
||||
requested_model?: string;
|
||||
effective_model?: string;
|
||||
fallback_model?: string;
|
||||
agent_id: string;
|
||||
executor_type: string;
|
||||
executor_profile_id: string;
|
||||
};
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type MCPAttachmentServerPayload = {
|
||||
name: string;
|
||||
source?: "kandev" | "profile";
|
||||
|
||||
@@ -7,7 +7,12 @@ import { createMessagesHandlerRegistration, createMessageUpdateScheduler } from
|
||||
type UpdatedMessage = BackendMessageMap["session.message.updated"];
|
||||
const TEST_TIMESTAMP = "2026-08-02T00:00:00.000Z";
|
||||
|
||||
function makePayload(sessionId: string, messageId: string, content: string) {
|
||||
function makePayload(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
session_id: sessionId,
|
||||
@@ -19,6 +24,7 @@ function makePayload(sessionId: string, messageId: string, content: string) {
|
||||
type: "message",
|
||||
created_at: TEST_TIMESTAMP,
|
||||
updated_at: "2026-08-02T00:00:01.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +38,7 @@ function makeUpdated(sessionId: string, messageId: string, content: string): Upd
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(currentMessages: Record<string, unknown[]> = {}) {
|
||||
function makeStore(currentMessages: Record<string, unknown[]> = {}, completedTurn = false) {
|
||||
const updateMessage = vi.fn();
|
||||
const addMessage = vi.fn();
|
||||
const removeMessage = vi.fn();
|
||||
@@ -41,6 +47,13 @@ function makeStore(currentMessages: Record<string, unknown[]> = {}) {
|
||||
addMessage,
|
||||
removeMessage,
|
||||
messages: { bySession: currentMessages },
|
||||
turns: {
|
||||
bySession: completedTurn
|
||||
? {
|
||||
"session-1": [{ id: "turn-1", completed_at: TEST_TIMESTAMP }],
|
||||
}
|
||||
: {},
|
||||
},
|
||||
};
|
||||
return {
|
||||
store: { getState: () => state } as unknown as StoreApi<AppState>,
|
||||
@@ -196,3 +209,55 @@ describe("session message snapshot ordering", () => {
|
||||
registration.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("late tool messages", () => {
|
||||
it("settles a tool message added after its turn completed", () => {
|
||||
const { store, addMessage } = makeStore({}, true);
|
||||
const registration = createMessagesHandlerRegistration(store);
|
||||
|
||||
registration.handlers["session.message.added"]!({
|
||||
id: "add-tool",
|
||||
type: "notification",
|
||||
action: "session.message.added",
|
||||
payload: makePayload("session-1", "tool-1", "Terminal", {
|
||||
type: "tool_call",
|
||||
metadata: { tool_call_id: "call-1", status: "running" },
|
||||
}),
|
||||
timestamp: TEST_TIMESTAMP,
|
||||
} as never);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { tool_call_id: "call-1", status: "complete" },
|
||||
}),
|
||||
);
|
||||
registration.dispose();
|
||||
});
|
||||
|
||||
it("does not let a late running update reintroduce a spinner", () => {
|
||||
const { store, updateMessage } = makeStore({}, true);
|
||||
const registration = createMessagesHandlerRegistration(store);
|
||||
|
||||
registration.handlers["session.message.updated"]!(
|
||||
makeUpdated("session-1", "tool-1", "Terminal") as never,
|
||||
);
|
||||
registration.handlers["session.message.updated"]!({
|
||||
id: "updated-tool",
|
||||
type: "notification",
|
||||
action: "session.message.updated",
|
||||
payload: makePayload("session-1", "tool-1", "Terminal", {
|
||||
type: "tool_call",
|
||||
metadata: { tool_call_id: "call-1", status: "running" },
|
||||
}),
|
||||
timestamp: TEST_TIMESTAMP,
|
||||
} as never);
|
||||
registration.scheduler.flush();
|
||||
|
||||
expect(updateMessage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { tool_call_id: "call-1", status: "complete" },
|
||||
}),
|
||||
);
|
||||
registration.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,36 @@ function toMessage(payload: MessagePayload): MessageUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
function isTerminalToolStatus(status: unknown): boolean {
|
||||
return (
|
||||
status === "complete" ||
|
||||
status === "error" ||
|
||||
status === "failed" ||
|
||||
status === "cancelled" ||
|
||||
status === "success"
|
||||
);
|
||||
}
|
||||
|
||||
function settleMessageForCompletedTurn(
|
||||
store: StoreApi<AppState>,
|
||||
message: MessageUpdate,
|
||||
): MessageUpdate {
|
||||
if (message.type === "permission_request" || !message.turn_id) return message;
|
||||
|
||||
const turn = store
|
||||
.getState()
|
||||
.turns?.bySession[message.session_id]?.find((candidate) => candidate.id === message.turn_id);
|
||||
if (!turn?.completed_at) return message;
|
||||
|
||||
const metadata = message.metadata as Record<string, unknown> | undefined;
|
||||
if (!metadata?.tool_call_id || isTerminalToolStatus(metadata.status)) return message;
|
||||
|
||||
return {
|
||||
...message,
|
||||
metadata: { ...metadata, status: "complete" },
|
||||
};
|
||||
}
|
||||
|
||||
function messageKey(payload: MessagePayload): string {
|
||||
return `${payload.session_id}:${payload.message_id}`;
|
||||
}
|
||||
@@ -98,7 +128,7 @@ export function createMessageUpdateScheduler(
|
||||
for (const payload of updates) {
|
||||
if (!payload.session_id || !payload.message_id) continue;
|
||||
if (isOlderThanCurrentSnapshot(store, payload)) continue;
|
||||
store.getState().updateMessage(toMessage(payload));
|
||||
store.getState().updateMessage(settleMessageForCompletedTurn(store, toMessage(payload)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,7 +165,7 @@ export function createMessagesHandlerRegistration(
|
||||
const payload = message.payload;
|
||||
if (!payload.session_id) return;
|
||||
scheduler.flush();
|
||||
store.getState().addMessage(toMessage(payload));
|
||||
store.getState().addMessage(settleMessageForCompletedTurn(store, toMessage(payload)));
|
||||
},
|
||||
"session.message.updated": (message) => {
|
||||
scheduler.enqueue(message.payload);
|
||||
|
||||
@@ -155,6 +155,23 @@
|
||||
"profileNotFound": "Profile not found",
|
||||
"profileSaved": "Profile saved",
|
||||
"profiles": "Profiles",
|
||||
"portableConfigAvailable": "Available",
|
||||
"portableConfigBundleClaudeSettings": "Copy Claude settings",
|
||||
"portableConfigBundleCodexConfig": "Copy Codex configuration",
|
||||
"portableConfigBundleGeneric": "Agent configuration ({{id}})",
|
||||
"portableConfigBundleMockSettings": "Copy mock-agent settings",
|
||||
"portableConfigBundleOpenCodeConfig": "Copy OpenCode configuration",
|
||||
"portableConfigDescription": "Copy selected agent settings, which may contain secrets or commands, into newly provisioned or reset environments.",
|
||||
"portableConfigFileMapping": "{{source}} to {{target}}",
|
||||
"portableConfigInfoCopyTiming": "Kandev copies selected files without changes only during fresh provisioning or environment reset. A fresh copy can replace an existing target file. Warm resumes keep the existing environment.",
|
||||
"portableConfigInfoErrors": "Missing or unreadable optional files are skipped with a warning and do not block launch. Files can contain secrets or environment values.",
|
||||
"portableConfigInfoLabel": "Portable agent configuration details",
|
||||
"portableConfigInfoScope": "Only allow-listed files are copied. A file can add hooks or commands, change models, permissions, MCP servers, or endpoints, and contain host paths that do not work in the executor. Authentication files and configuration bundles are separate choices.",
|
||||
"portableConfigInfoSsh": "On SSH, the target is the configured remote user's shared home and can replace configuration used by other processes on that host.",
|
||||
"portableConfigInfoTitle": "Portable agent configuration",
|
||||
"portableConfigNotFound": "Not found",
|
||||
"portableConfigNotFoundHint": "This is an editing hint. You can save the profile and add the file later.",
|
||||
"portableConfigTitle": "Agent configuration",
|
||||
"provideSecret": "Provide secret",
|
||||
"quickPresets": "Quick presets",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -488,6 +488,7 @@
|
||||
"profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents",
|
||||
"programming": "Programming",
|
||||
"profileFallbackWillBeUsed": "Start model {{model}} is no longer available; fallback {{fallback}} will be used.",
|
||||
"profileStartModelNotAdvertisedOnHost": "The host probe did not advertise {{model}}. The selected executor will decide the model at launch.",
|
||||
"profileStartModelUnavailable": "Start model {{model}} is no longer available; change it in the agent profile.",
|
||||
"promptAdd": "Add prompt",
|
||||
"promptContentPlaceholder": "Prompt content",
|
||||
|
||||
@@ -732,6 +732,26 @@
|
||||
"messageTime": "Message time",
|
||||
"modKeyEnterToAdd": "{{modKey}}+Enter to add",
|
||||
"model": "model",
|
||||
"modelSelectionAgent": "Agent",
|
||||
"modelSelectionEffective": "Effective model",
|
||||
"modelSelectionExecutor": "Executor",
|
||||
"modelSelectionExecutorProfile": "Executor profile",
|
||||
"modelSelectionFallback": "Advertised fallback",
|
||||
"modelSelectionProviderDefault": "Agent default",
|
||||
"modelSelectionReason": "Reason",
|
||||
"modelSelectionReasonAutoFallback": "The advertised model could not be applied, so automatic fallback continued with the agent default.",
|
||||
"modelSelectionReasonCatalogEmpty": "The executor did not advertise a model catalog.",
|
||||
"modelSelectionReasonFallbackNotAdvertised": "The saved fallback model was not advertised by the executor.",
|
||||
"modelSelectionReasonRequestedNotAdvertised": "The saved model was not advertised by the executor.",
|
||||
"modelSelectionReasonUnknown": "{{reason}}",
|
||||
"modelSelectionReasonUnsupported": "The executor does not support model selection.",
|
||||
"modelSelectionRemediationAgentVersion": "Check the agent version in the executor.",
|
||||
"modelSelectionRemediationConfiguration": "Check the copied agent configuration.",
|
||||
"modelSelectionRemediationCredentials": "Check executor credentials.",
|
||||
"modelSelectionRemediationTitle": "Check these items",
|
||||
"modelSelectionRemediationUnknown": "Check the executor configuration.",
|
||||
"modelSelectionRequested": "Requested model",
|
||||
"modelSelectionWarning": "The executor could not use the saved model selection.",
|
||||
"monitor": "Monitor",
|
||||
"monitorCommand": "Monitor: {{command}}",
|
||||
"moreActions": "More actions",
|
||||
|
||||
@@ -155,6 +155,23 @@
|
||||
"profileNotFound": "Ƥŕōƒĩĺē ńōţ ƒōũńď",
|
||||
"profileSaved": "Ƥŕōƒĩĺē śàvēď",
|
||||
"profiles": "Ƥŕōƒĩĺēś",
|
||||
"portableConfigAvailable": "Àvàĩĺàƀĺē",
|
||||
"portableConfigBundleClaudeSettings": "Ćōƥŷ Ćĺàũďē śēţţĩńĝś",
|
||||
"portableConfigBundleCodexConfig": "Ćōƥŷ Ćōďēx ćōńƒĩĝũŕàţĩōń",
|
||||
"portableConfigBundleGeneric": "Àĝēńţ ćōńƒĩĝũŕàţĩōń ({{id}})",
|
||||
"portableConfigBundleMockSettings": "Ćōƥŷ ḿōćķ-àĝēńţ śēţţĩńĝś",
|
||||
"portableConfigBundleOpenCodeConfig": "Ćōƥŷ ŌƥēńĆōďē ćōńƒĩĝũŕàţĩōń",
|
||||
"portableConfigDescription": "Ćōƥŷ śēĺēćţēď àĝēńţ śēţţĩńĝś, ŵĥĩćĥ ḿàŷ ćōńţàĩń śēćŕēţś ōŕ ćōḿḿàńďś, ĩńţō ńēŵĺŷ ƥŕōvĩśĩōńēď ōŕ ŕēśēţ ēńvĩŕōńḿēńţś.",
|
||||
"portableConfigFileMapping": "{{source}} ţō {{target}}",
|
||||
"portableConfigInfoCopyTiming": "Ķàńďēv ćōƥĩēś śēĺēćţēď ƒĩĺēś ŵĩţĥōũţ ćĥàńĝēś ōńĺŷ ďũŕĩńĝ ƒŕēśĥ ƥŕōvĩśĩōńĩńĝ ōŕ ēńvĩŕōńḿēńţ ŕēśēţ. À ƒŕēśĥ ćōƥŷ ćàń ŕēƥĺàćē àń ēxĩśţĩńĝ ţàŕĝēţ ƒĩĺē. Ŵàŕḿ ŕēśũḿēś ķēēƥ ţĥē ēxĩśţĩńĝ ēńvĩŕōńḿēńţ.",
|
||||
"portableConfigInfoErrors": "Ḿĩśśĩńĝ ōŕ ũńŕēàďàƀĺē ōƥţĩōńàĺ ƒĩĺēś àŕē śķĩƥƥēď ŵĩţĥ à ŵàŕńĩńĝ àńď ďō ńōţ ƀĺōćķ ĺàũńćĥ. Ƒĩĺēś ćàń ćōńţàĩń śēćŕēţś ōŕ ēńvĩŕōńḿēńţ vàĺũēś.",
|
||||
"portableConfigInfoLabel": "Ƥōŕţàƀĺē àĝēńţ ćōńƒĩĝũŕàţĩōń ďēţàĩĺś",
|
||||
"portableConfigInfoScope": "Ōńĺŷ àĺĺōŵ-ĺĩśţēď ƒĩĺēś àŕē ćōƥĩēď. À ƒĩĺē ćàń àďď ĥōōķś ōŕ ćōḿḿàńďś, ćĥàńĝē ḿōďēĺś, ƥēŕḿĩśśĩōńś, ḾĆƤ śēŕvēŕś, ōŕ ēńďƥōĩńţś, àńď ćōńţàĩń ĥōśţ ƥàţĥś ţĥàţ ďō ńōţ ŵōŕķ ĩń ţĥē ēxēćũţōŕ. Àũţĥēńţĩćàţĩōń ƒĩĺēś àńď ćōńƒĩĝũŕàţĩōń ƀũńďĺēś àŕē śēƥàŕàţē ćĥōĩćēś.",
|
||||
"portableConfigInfoSsh": "Ōń ŚŚĤ, ţĥē ţàŕĝēţ ĩś ţĥē ćōńƒĩĝũŕēď ŕēḿōţē ũśēŕ'ś śĥàŕēď ĥōḿē àńď ćàń ŕēƥĺàćē ćōńƒĩĝũŕàţĩōń ũśēď ƀŷ ōţĥēŕ ƥŕōćēśśēś ōń ţĥàţ ĥōśţ.",
|
||||
"portableConfigInfoTitle": "Ƥōŕţàƀĺē àĝēńţ ćōńƒĩĝũŕàţĩōń",
|
||||
"portableConfigNotFound": "Ńōţ ƒōũńď",
|
||||
"portableConfigNotFoundHint": "Ţĥĩś ĩś àń ēďĩţĩńĝ ĥĩńţ. Ŷōũ ćàń śàvē ţĥē ƥŕōƒĩĺē àńď àďď ţĥē ƒĩĺē ĺàţēŕ.",
|
||||
"portableConfigTitle": "Àĝēńţ ćōńƒĩĝũŕàţĩōń",
|
||||
"provideSecret": "Ƥŕōvĩďē śēćŕēţ",
|
||||
"quickPresets": "Qũĩćķ ƥŕēśēţś",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
|
||||
@@ -488,6 +488,7 @@
|
||||
"profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś",
|
||||
"programming": "Ƥŕōĝŕàḿḿĩńĝ",
|
||||
"profileFallbackWillBeUsed": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē; ƒàĺĺƀàćķ {{fallback}} ŵĩĺĺ ƀē ũśēď.",
|
||||
"profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.",
|
||||
"profileStartModelUnavailable": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē; ćĥàńĝē ĩţ ĩń ţĥē àĝēńţ ƥŕōƒĩĺē.",
|
||||
"promptAdd": "Àďď ƥŕōḿƥţ",
|
||||
"promptContentPlaceholder": "Ƥŕōḿƥţ ćōńţēńţ",
|
||||
|
||||
@@ -732,6 +732,26 @@
|
||||
"messageTime": "Ḿēśśàĝē ţĩḿē",
|
||||
"modKeyEnterToAdd": "{{modKey}}+Ēńţēŕ ţō àďď",
|
||||
"model": "ḿōďēĺ",
|
||||
"modelSelectionAgent": "Àĝēńţ",
|
||||
"modelSelectionEffective": "Ēƒƒēćţĩvē ḿōďēĺ",
|
||||
"modelSelectionExecutor": "Ēxēćũţōŕ",
|
||||
"modelSelectionExecutorProfile": "Ēxēćũţōŕ ƥŕōƒĩĺē",
|
||||
"modelSelectionFallback": "Àďvēŕţĩśēď ƒàĺĺƀàćķ",
|
||||
"modelSelectionProviderDefault": "Àĝēńţ ďēƒàũĺţ",
|
||||
"modelSelectionReason": "Ŕēàśōń",
|
||||
"modelSelectionReasonAutoFallback": "Ţĥē àďvēŕţĩśēď ḿōďēĺ ćōũĺď ńōţ ƀē àƥƥĺĩēď, śō àũţōḿàţĩć ƒàĺĺƀàćķ ćōńţĩńũēď ŵĩţĥ ţĥē àĝēńţ ďēƒàũĺţ.",
|
||||
"modelSelectionReasonCatalogEmpty": "Ţĥē ēxēćũţōŕ ďĩď ńōţ àďvēŕţĩśē à ḿōďēĺ ćàţàĺōĝ.",
|
||||
"modelSelectionReasonFallbackNotAdvertised": "Ţĥē śàvēď ƒàĺĺƀàćķ ḿōďēĺ ŵàś ńōţ àďvēŕţĩśēď ƀŷ ţĥē ēxēćũţōŕ.",
|
||||
"modelSelectionReasonRequestedNotAdvertised": "Ţĥē śàvēď ḿōďēĺ ŵàś ńōţ àďvēŕţĩśēď ƀŷ ţĥē ēxēćũţōŕ.",
|
||||
"modelSelectionReasonUnknown": "{{reason}}",
|
||||
"modelSelectionReasonUnsupported": "Ţĥē ēxēćũţōŕ ďōēś ńōţ śũƥƥōŕţ ḿōďēĺ śēĺēćţĩōń.",
|
||||
"modelSelectionRemediationAgentVersion": "Ćĥēćķ ţĥē àĝēńţ vēŕśĩōń ĩń ţĥē ēxēćũţōŕ.",
|
||||
"modelSelectionRemediationConfiguration": "Ćĥēćķ ţĥē ćōƥĩēď àĝēńţ ćōńƒĩĝũŕàţĩōń.",
|
||||
"modelSelectionRemediationCredentials": "Ćĥēćķ ēxēćũţōŕ ćŕēďēńţĩàĺś.",
|
||||
"modelSelectionRemediationTitle": "Ćĥēćķ ţĥēśē ĩţēḿś",
|
||||
"modelSelectionRemediationUnknown": "Ćĥēćķ ţĥē ēxēćũţōŕ ćōńƒĩĝũŕàţĩōń.",
|
||||
"modelSelectionRequested": "Ŕēqũēśţēď ḿōďēĺ",
|
||||
"modelSelectionWarning": "Ţĥē ēxēćũţōŕ ćōũĺď ńōţ ũśē ţĥē śàvēď ḿōďēĺ śēĺēćţĩōń.",
|
||||
"monitor": "Ḿōńĩţōŕ",
|
||||
"monitorCommand": "Ḿōńĩţōŕ: {{command}}",
|
||||
"moreActions": "Ḿōŕē àćţĩōńś",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Settings typography audit
|
||||
|
||||
Status: Active implementation audit
|
||||
Status: Active implementation audit
|
||||
Last reviewed: 2026-08-16
|
||||
|
||||
This audit covers the first-party settings routes, shared settings components, the settings navigation, workspace and integration settings, system and account settings, and the existing settings E2E coverage.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user