Files
kandev/apps/backend/internal/agent/runtime/lifecycle/skill_deploy_test.go
T
2026-05-16 20:07:57 +01:00

194 lines
6.4 KiB
Go

package lifecycle
import (
"context"
"errors"
"sync/atomic"
"testing"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
// 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
}
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_FastPathEmptyProfile verifies that a shallow / kanban
// profile (empty skill_ids, empty desired_skills) short-circuits before
// invoking the deployer.
func TestRunSkillDeploy_FastPathEmptyProfile(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() != 0 {
t.Errorf("shallow profile should fast-path, deployer was called %d times", rec.called.Load())
}
}
// 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_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())
}
}