Files
kandev/apps/backend/internal/agent/runtime/lifecycle/skill_deploy_test.go
T

329 lines
11 KiB
Go

package lifecycle
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/mcpmode"
)
// fakeProfileReader returns a canned profile (or error) so we can drive the
// skill-deploy hook without wiring the full settings store.
type fakeProfileReader struct {
profile *settingsmodels.AgentProfile
err error
}
func (f *fakeProfileReader) GetAgentProfile(_ context.Context, _ string) (*settingsmodels.AgentProfile, error) {
return f.profile, f.err
}
// recordingDeployer captures whether DeploySkills was called and lets tests
// inject a return value.
type recordingDeployer struct {
called atomic.Int32
last SkillDeployRequest
result SkillDeployResult
err error
}
type lifecycleSkillReader struct {
skill *runtimeskill.Skill
}
func (r *lifecycleSkillReader) GetSkillFromConfig(_ context.Context, key string) (*runtimeskill.Skill, error) {
if key == r.skill.Slug {
return r.skill, nil
}
return nil, nil
}
func (r *recordingDeployer) DeploySkills(_ context.Context, req SkillDeployRequest) (SkillDeployResult, error) {
r.called.Add(1)
r.last = req
return r.result, r.err
}
func newSkillDeployTestManager(t *testing.T) *Manager {
t.Helper()
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
return &Manager{
logger: log,
skillDeployer: NoopSkillDeployer(),
}
}
// TestRunSkillDeploy_NoOpWithoutReader verifies the launch-prep hook
// short-circuits when no profile reader has been wired.
func TestRunSkillDeploy_NoOpWithoutReader(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
// agentProfileReader stays nil — should bail before reaching the deployer.
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1"},
&LaunchRequest{WorkspacePath: "/tmp/ws"})
if rec.called.Load() != 0 {
t.Errorf("expected deployer to not be called, was called %d times", rec.called.Load())
}
}
// TestRunSkillDeploy_EmptyProfileInvokesDeployerForCleanup verifies that a
// shallow / kanban profile still reaches the deployer so it can remove a
// launch-scoped skill left by an earlier seat run.
func TestRunSkillDeploy_EmptyProfileInvokesDeployerForCleanup(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{ID: "p1", AgentID: "a1"},
}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1"},
&LaunchRequest{WorkspacePath: "/tmp/ws", ExecutorType: "local_pc"})
if rec.called.Load() != 1 {
t.Errorf("empty profile should invoke cleanup, deployer was called %d times", rec.called.Load())
}
}
func TestRunSkillDeploy_AdditionalSkillSlugInvokesDeployerForEmptyProfile(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{ID: "p1", AgentID: "a1"},
}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{
AgentProfileID: "p1",
AdditionalSkillSlugs: []string{"kandev-step-decision"},
},
&LaunchRequest{WorkspacePath: "/tmp/ws", ExecutorType: "local_pc"})
if rec.called.Load() != 1 {
t.Fatalf("expected deployer once, got %d", rec.called.Load())
}
if len(rec.last.AdditionalSkillSlugs) != 1 || rec.last.AdditionalSkillSlugs[0] != "kandev-step-decision" {
t.Fatalf("additional skill slugs = %v", rec.last.AdditionalSkillSlugs)
}
}
func TestRunSkillDeploy_SeatThenNonSeatCleansMaterializedDecisionSkill(t *testing.T) {
base := t.TempDir()
worktree := t.TempDir()
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
deployer, err := runtimeskill.New(runtimeskill.Config{
Logger: log,
BasePath: base,
SkillReader: &lifecycleSkillReader{skill: &runtimeskill.Skill{
Slug: runtimeskill.ReservedDecisionSkillSlug, Content: "# decision",
}},
ProjectSkillDirResolver: func(string) string { return ".claude/skills" },
})
if err != nil {
t.Fatalf("skill.New: %v", err)
}
mgr := newSkillDeployTestManager(t)
mgr.skillDeployer = NewSkillDeployerAdapter(deployer)
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{ID: "seat-profile", AgentID: "claude-acp"},
}
prepared := &LaunchRequest{WorkspacePath: worktree, ExecutorType: "local_pc"}
mgr.runSkillDeploy(context.Background(), &LaunchRequest{
AgentProfileID: "seat-profile",
AdditionalSkillSlugs: []string{
runtimeskill.ReservedDecisionSkillSlug,
},
}, prepared)
decisionPath := filepath.Join(worktree, ".claude", "skills", "kandev-step-decision", "SKILL.md")
if _, err := os.Stat(decisionPath); err != nil {
t.Fatalf("seat launch did not materialize decision skill: %v", err)
}
mgr.runSkillDeploy(context.Background(), &LaunchRequest{AgentProfileID: "seat-profile"}, prepared)
if _, err := os.Stat(decisionPath); !os.IsNotExist(err) {
t.Fatalf("non-seat launch left decision skill at %s", decisionPath)
}
}
// TestRunSkillDeploy_RichProfileInvokesDeployer verifies the deployer fires
// when the profile carries any of skill_ids or desired_skills.
func TestRunSkillDeploy_RichProfileInvokesDeployer(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
rich := &settingsmodels.AgentProfile{
ID: "p1",
AgentID: "a1",
WorkspaceID: "ws-1",
SkillIDs: `["sk-foo"]`,
}
mgr.agentProfileReader = &fakeProfileReader{profile: rich}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1", SessionID: "sess-1"},
&LaunchRequest{WorkspacePath: "/tmp/ws", ExecutorType: "local_docker"})
if rec.called.Load() != 1 {
t.Fatalf("expected deployer once, got %d", rec.called.Load())
}
if rec.last.Profile != rich {
t.Errorf("deployer received wrong profile: %+v", rec.last.Profile)
}
if rec.last.WorkspacePath != "/tmp/ws" || rec.last.ExecutorType != "local_docker" {
t.Errorf("deployer received wrong context: %+v", rec.last)
}
if rec.last.WorkspaceID != "ws-1" || rec.last.SessionID != "sess-1" {
t.Errorf("deployer received wrong ids: %+v", rec.last)
}
}
// TestRunSkillDeploy_DeployerErrorDoesNotAbort verifies that a deployer
// error is logged but does not propagate (launch must keep going).
func TestRunSkillDeploy_DeployerErrorDoesNotAbort(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{err: errors.New("disk full")}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{
ID: "p1", AgentID: "a1", SkillIDs: `["sk-x"]`,
},
}
// Must not panic, must not propagate. We assert by reaching the line
// after the call and by checking the deployer was actually invoked.
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1"},
&LaunchRequest{WorkspacePath: "/tmp/ws"})
if rec.called.Load() != 1 {
t.Errorf("deployer should still have been called, got %d", rec.called.Load())
}
}
// TestRunSkillDeploy_MergesMetadataOnPreparedRequest verifies that any
// metadata patches and instructions directory the deployer returns are
// merged onto the prepared LaunchRequest so executor backends pick them
// up (Sprites manifest upload, instructions-dir hint).
func TestRunSkillDeploy_MergesMetadataOnPreparedRequest(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{
result: SkillDeployResult{
Metadata: map[string]any{
MetadataKeySkillManifestJSON: `{"Skills":[]}`,
},
InstructionsDir: "/tmp/kandev/runtime/default/instructions/p1",
},
}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{
ID: "p1", AgentID: "a1", SkillIDs: `["sk-y"]`,
},
}
prepared := &LaunchRequest{WorkspacePath: "/tmp/ws", ExecutorType: "local_docker"}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1"}, prepared)
if rec.called.Load() != 1 {
t.Fatalf("expected deployer once, got %d", rec.called.Load())
}
if got := prepared.Metadata[MetadataKeySkillManifestJSON]; got != `{"Skills":[]}` {
t.Errorf("manifest metadata = %v", got)
}
if got := prepared.Metadata[MetadataKeyInstructionsDir]; got != "/tmp/kandev/runtime/default/instructions/p1" {
t.Errorf("instructions dir metadata = %v", got)
}
}
// TestRunSkillDeploy_OfficeRuntimeRequiresOfficeMode verifies that system
// skills are enabled only for an Office-mode launch with a usable CLI path.
func TestRunSkillDeploy_OfficeRuntimeRequiresOfficeMode(t *testing.T) {
rich := &settingsmodels.AgentProfile{ID: "p1", AgentID: "a1", SkillIDs: `["sk-foo"]`}
tests := []struct {
name string
mode string
env map[string]string
want bool
}{
{
name: "Office mode with CLI",
mode: mcpmode.Office,
env: map[string]string{"KANDEV_CLI": "/usr/local/bin/agentctl"},
want: true,
},
{
name: "task mode with CLI",
mode: mcpmode.Task,
env: map[string]string{"KANDEV_CLI": "/usr/local/bin/agentctl"},
want: false,
},
{
name: "Office mode with whitespace CLI",
mode: mcpmode.Office,
env: map[string]string{"KANDEV_CLI": " "},
want: false,
},
{
name: "Office mode without CLI",
mode: mcpmode.Office,
env: map[string]string{"GITLAB_TOKEN": "x"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{profile: rich}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{AgentProfileID: "p1"},
&LaunchRequest{WorkspacePath: "/tmp/ws", Env: tt.env, McpMode: tt.mode})
if rec.called.Load() != 1 {
t.Fatalf("expected deployer once, got %d", rec.called.Load())
}
if rec.last.OfficeRuntime != tt.want {
t.Errorf("OfficeRuntime = %t, want %t", rec.last.OfficeRuntime, tt.want)
}
})
}
}
// TestRunSkillDeploy_NoProfileID verifies that a launch without a profile id
// (legacy / pass-through executions) short-circuits.
func TestRunSkillDeploy_NoProfileID(t *testing.T) {
mgr := newSkillDeployTestManager(t)
rec := &recordingDeployer{}
mgr.skillDeployer = rec
mgr.agentProfileReader = &fakeProfileReader{
profile: &settingsmodels.AgentProfile{ID: "p1", SkillIDs: `["sk-x"]`},
}
mgr.runSkillDeploy(context.Background(),
&LaunchRequest{},
&LaunchRequest{WorkspacePath: "/tmp/ws"})
if rec.called.Load() != 0 {
t.Errorf("missing profile id should skip deploy, called %d times", rec.called.Load())
}
}