194 lines
6.4 KiB
Go
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())
|
|
}
|
|
}
|