diff --git a/go/api/client/agenttemplate.go b/go/api/client/agenttemplate.go new file mode 100644 index 000000000..48ff8b2cc --- /dev/null +++ b/go/api/client/agenttemplate.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" +) + +// AgentTemplateClient provides supported AgentTemplate operations. +type AgentTemplateClient struct { + client *BaseClient +} + +// NewAgentTemplateClient creates an AgentTemplate client over the shared gRPC connection. +func NewAgentTemplateClient(client *BaseClient) *AgentTemplateClient { + return &AgentTemplateClient{client: client} +} + +func (c *AgentTemplateClient) CreateAgentTemplate(ctx context.Context, request *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) { + client, callContext, cancel, err := c.client.agentTemplateCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.CreateAgentTemplate(callContext, request) +} + +func (c *AgentTemplateClient) UpdateAgentTemplate(ctx context.Context, request *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) { + client, callContext, cancel, err := c.client.agentTemplateCall(ctx) + if err != nil { + return nil, err + } + defer cancel() + return client.UpdateAgentTemplate(callContext, request) +} + +func (c *BaseClient) agentTemplateCall(ctx context.Context) (apiv1alpha1.AgentTemplateServiceClient, context.Context, context.CancelFunc, error) { + connection, err := c.grpcConnection() + if err != nil { + return nil, nil, nil, err + } + callContext, cancel := c.grpcCallContext(ctx) + return apiv1alpha1.NewAgentTemplateServiceClient(connection), callContext, cancel, nil +} diff --git a/go/api/client/agenttemplategrpc_test.go b/go/api/client/agenttemplategrpc_test.go new file mode 100644 index 000000000..2c55bbff0 --- /dev/null +++ b/go/api/client/agenttemplategrpc_test.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "net" + "sync" + "testing" + "time" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" +) + +type recordingAgentTemplateService struct { + apiv1alpha1.UnimplementedAgentTemplateServiceServer + + mu sync.Mutex + observations []callObservation + create *apiv1alpha1.CreateAgentTemplateRequest + update *apiv1alpha1.UpdateAgentTemplateRequest +} + +func (s *recordingAgentTemplateService) CreateAgentTemplate(ctx context.Context, request *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) { + s.observe(ctx) + s.mu.Lock() + defer s.mu.Unlock() + s.create = request + return &apiv1alpha1.CreateAgentTemplateResponse{}, nil +} + +func (s *recordingAgentTemplateService) UpdateAgentTemplate(ctx context.Context, request *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) { + s.observe(ctx) + s.mu.Lock() + defer s.mu.Unlock() + s.update = request + return &apiv1alpha1.UpdateAgentTemplateResponse{}, nil +} + +func (s *recordingAgentTemplateService) observe(ctx context.Context) { + values, _ := metadata.FromIncomingContext(ctx) + _, hasDeadline := ctx.Deadline() + s.mu.Lock() + defer s.mu.Unlock() + s.observations = append(s.observations, callObservation{userID: first(values.Get(userIDHeader)), hasDeadline: hasDeadline}) +} + +func TestAgentTemplateClientUsesGeneratedGRPC(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + service := &recordingAgentTemplateService{} + server := grpc.NewServer() + apiv1alpha1.RegisterAgentTemplateServiceServer(server, service) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + clientSet := New( + "http://rest-must-not-be-used.invalid", + WithUserID("caller"), + WithGRPCTarget("passthrough:///bufnet"), + WithGRPCTimeout(5*time.Second), + WithGRPCDialOptions(grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + })), + ) + t.Cleanup(func() { require.NoError(t, clientSet.Close()) }) + + ref := &apiv1alpha1.ResourceReference{Namespace: "team-a", Name: "researcher"} + _, err := clientSet.AgentTemplate.CreateAgentTemplate(t.Context(), &apiv1alpha1.CreateAgentTemplateRequest{Ref: ref}) + require.NoError(t, err) + _, err = clientSet.AgentTemplate.UpdateAgentTemplate(t.Context(), &apiv1alpha1.UpdateAgentTemplateRequest{Ref: ref}) + require.NoError(t, err) + service.mu.Lock() + defer service.mu.Unlock() + assert.True(t, proto.Equal(ref, service.create.GetRef())) + assert.True(t, proto.Equal(ref, service.update.GetRef())) + assert.Equal(t, []callObservation{{userID: "caller", hasDeadline: true}, {userID: "caller", hasDeadline: true}}, service.observations) +} diff --git a/go/api/client/clientset.go b/go/api/client/clientset.go index 6c68b9971..041aab980 100644 --- a/go/api/client/clientset.go +++ b/go/api/client/clientset.go @@ -16,6 +16,7 @@ type ClientSet struct { Namespace Namespace Feedback Feedback AgentInstance *AgentInstanceClient + AgentTemplate *AgentTemplateClient A2A *A2AClient } @@ -37,6 +38,7 @@ func New(baseURL string, options ...ClientOption) *ClientSet { Namespace: NewNamespaceClient(baseClient), Feedback: NewFeedbackClient(baseClient), AgentInstance: NewAgentInstanceClient(baseClient), + AgentTemplate: NewAgentTemplateClient(baseClient), A2A: NewA2AClient(baseClient), } } diff --git a/go/core/cli/internal/commands/agent_template.go b/go/core/cli/internal/commands/agent_template.go index 2d38521b0..eafd8b047 100644 --- a/go/core/cli/internal/commands/agent_template.go +++ b/go/core/cli/internal/commands/agent_template.go @@ -5,21 +5,31 @@ import ( "errors" "fmt" "io" + "os" "strings" "time" "github.com/jedib0t/go-pretty/v6/table" typedapiv1alpha3 "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/api/structuredobject" apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" commonk8s "github.com/kagent-dev/kagent/go/core/cli/internal/common/k8s" "github.com/kagent-dev/kagent/go/core/cli/internal/connection" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/output" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" ) -const agentTemplateMaxPageSize = 100 +const ( + agentTemplateKind = "AgentTemplate" + agentTemplateMaxPageSize = 100 +) // AgentTemplateGetCfg configures AgentTemplate get and list operations. type AgentTemplateGetCfg struct { @@ -30,6 +40,105 @@ type AgentTemplateGetCfg struct { PageToken string } +// AgentTemplateManifestCfg configures an AgentTemplate manifest operation. +type AgentTemplateManifestCfg struct { + OutputFormat string + File string +} + +type lifecycleClient interface { + CreateAgentTemplate(context.Context, *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) + UpdateAgentTemplate(context.Context, *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) +} + +type agentTemplateManifestOperation func(context.Context, lifecycleClient, *apiv1alpha1.ResourceReference, *apiv1alpha1.StructuredObject, clioutput.Format, io.Writer) error + +func runAgentTemplateManifest( + ctx context.Context, + options connection.Options, + cfg *AgentTemplateManifestCfg, + out io.Writer, + operation agentTemplateManifestOperation, +) (err error) { + format, err := clioutput.Parse(cfg.OutputFormat) + if err != nil { + return err + } + ref, resource, err := readAgentTemplateManifest(cfg.File, options.Namespace) + if err != nil { + return err + } + session, err := connection.Open(ctx, options) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, session.Close()) + }() + return operation(ctx, session.Client.AgentTemplate, ref, resource, format, out) +} + +func readAgentTemplateManifest(filename, namespace string) (*apiv1alpha1.ResourceReference, *apiv1alpha1.StructuredObject, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, nil, fmt.Errorf("read AgentTemplate manifest %q: %w", filename, err) + } + manifest := &apiv1alpha3.AgentTemplate{} + if err := yaml.UnmarshalStrict(data, manifest); err != nil { + return nil, nil, fmt.Errorf("parse AgentTemplate manifest %q: %w", filename, err) + } + if manifest.APIVersion != apiv1alpha3.GroupVersion.String() || manifest.Kind != agentTemplateKind { + return nil, nil, fmt.Errorf("AgentTemplate manifest %q must have apiVersion %q and kind %q", filename, apiv1alpha3.GroupVersion.String(), agentTemplateKind) + } + if manifest.Name == "" { + return nil, nil, fmt.Errorf("AgentTemplate manifest %q must have metadata.name", filename) + } + if manifest.Namespace != "" && manifest.Namespace != namespace { + return nil, nil, fmt.Errorf("AgentTemplate manifest namespace %q does not match --namespace %q", manifest.Namespace, namespace) + } + resource, err := structuredobject.FromGo(manifest, apiv1alpha3.GroupVersion.String(), agentTemplateKind, 0) + if err != nil { + return nil, nil, fmt.Errorf("encode AgentTemplate manifest %q: %w", filename, err) + } + return &apiv1alpha1.ResourceReference{Namespace: namespace, Name: manifest.Name}, resource, nil +} + +func applyAgentTemplate( + ctx context.Context, + client lifecycleClient, + ref *apiv1alpha1.ResourceReference, + resource *apiv1alpha1.StructuredObject, + format clioutput.Format, + out io.Writer, +) error { + created, err := client.CreateAgentTemplate(ctx, &apiv1alpha1.CreateAgentTemplateRequest{Ref: ref, Resource: resource}) + if status.Code(err) != codes.AlreadyExists { + if err != nil { + return fmt.Errorf("apply AgentTemplate: %w", err) + } + return writeAgentTemplateResult(out, format, created, created.GetAgentTemplate()) + } + updated, err := client.UpdateAgentTemplate(ctx, &apiv1alpha1.UpdateAgentTemplateRequest{Ref: ref, Resource: resource}) + if err != nil { + return fmt.Errorf("apply AgentTemplate: %w", err) + } + return writeAgentTemplateResult(out, format, updated, updated.GetAgentTemplate()) +} + +func writeAgentTemplateResult(w io.Writer, format clioutput.Format, response proto.Message, result *apiv1alpha1.AgentTemplate) error { + if result == nil { + return errors.New("AgentTemplate operation returned no AgentTemplate") + } + if format == clioutput.FormatJSON { + return clioutput.WriteProto(w, response) + } + template := &apiv1alpha3.AgentTemplate{} + if err := structuredobject.ToGo(result.GetResource(), agentTemplateKind, template, 0); err != nil { + return fmt.Errorf("decode AgentTemplate result: %w", err) + } + return writeAgentTemplatesTable(w, []apiv1alpha3.AgentTemplate{*template}, false, "") +} + // runGetAgentTemplate gets one AgentTemplate or lists AgentTemplates through Kubernetes. func runGetAgentTemplate(ctx context.Context, cfg *AgentTemplateGetCfg, out io.Writer) error { format, err := clioutput.Parse(cfg.OutputFormat) @@ -154,3 +263,32 @@ func NewGetAgentTemplateCmd() *cobra.Command { cmd.Flags().StringVar(&cfg.PageToken, "page-token", "", "Token returned by the previous page") return cmd } + +// NewApplyAgentTemplateCmd constructs the AgentTemplate apply command. +func NewApplyAgentTemplateCmd() *cobra.Command { + return newAgentTemplateManifestCmd("apply -f FILE", "Create or update an AgentTemplate", applyAgentTemplate) +} + +func newAgentTemplateManifestCmd(use, short string, operation agentTemplateManifestOperation) *cobra.Command { + cfg := &AgentTemplateManifestCfg{} + cmd := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + options, err := connection.OptionsFromCommand(cmd) + if err != nil { + return err + } + format, err := clioutput.FromCommand(cmd) + if err != nil { + return err + } + cfg.OutputFormat = format + return runAgentTemplateManifest(cmd.Context(), options, cfg, cmd.OutOrStdout(), operation) + }, + } + cmd.Flags().StringVarP(&cfg.File, "file", "f", "", "Path to AgentTemplate manifest") + _ = cmd.MarkFlagRequired("file") + return cmd +} diff --git a/go/core/cli/internal/commands/agent_template_test.go b/go/core/cli/internal/commands/agent_template_test.go index 95a4aec65..7b42444ce 100644 --- a/go/core/cli/internal/commands/agent_template_test.go +++ b/go/core/cli/internal/commands/agent_template_test.go @@ -4,13 +4,21 @@ import ( "bytes" "context" "encoding/json" + "os" + "path/filepath" "testing" clientfake "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/api/structuredobject" apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" k8stesting "k8s.io/client-go/testing" @@ -115,3 +123,92 @@ func templateWithReadyCondition(name, harness string, status metav1.ConditionSta }}}, } } + +func TestReadAgentTemplateManifest(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "template.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(` +apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: researcher +spec: + modelConfig: + name: default +`), 0o600)) + + ref, resource, err := readAgentTemplateManifest(manifestPath, "team-a") + require.NoError(t, err) + assert.Equal(t, &apiv1alpha1.ResourceReference{Namespace: "team-a", Name: "researcher"}, ref) + decoded := &apiv1alpha3.AgentTemplate{} + require.NoError(t, structuredobject.ToGo(resource, agentTemplateKind, decoded, 0)) + assert.Equal(t, "default", decoded.Spec.ModelConfig.Name) + + require.NoError(t, os.WriteFile(manifestPath, []byte(` +apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: researcher + namespace: other +spec: + modelConfig: + name: default +`), 0o600)) + _, _, err = readAgentTemplateManifest(manifestPath, "team-a") + require.ErrorContains(t, err, `does not match --namespace`) +} + +func TestAgentTemplateLifecycleCommands(t *testing.T) { + template := testAgentTemplateMessage(t) + ref := template.GetRef() + resource := template.GetResource() + + t.Run("apply creates", func(t *testing.T) { + client := &recordingAgentTemplateClient{template: template} + var output bytes.Buffer + require.NoError(t, applyAgentTemplate(t.Context(), client, ref, resource, clioutput.FormatTable, &output)) + assert.True(t, proto.Equal(&apiv1alpha1.CreateAgentTemplateRequest{Ref: ref, Resource: resource}, client.createRequest)) + assert.Contains(t, output.String(), "researcher") + }) + + t.Run("apply updates existing", func(t *testing.T) { + client := &recordingAgentTemplateClient{template: template, createErr: status.Error(codes.AlreadyExists, "exists")} + require.NoError(t, applyAgentTemplate(t.Context(), client, ref, resource, clioutput.FormatTable, &bytes.Buffer{})) + assert.NotNil(t, client.createRequest) + assert.True(t, proto.Equal(&apiv1alpha1.UpdateAgentTemplateRequest{Ref: ref, Resource: resource}, client.updateRequest)) + }) +} + +func testAgentTemplateMessage(t *testing.T) *apiv1alpha1.AgentTemplate { + t.Helper() + template := &apiv1alpha3.AgentTemplate{ + TypeMeta: metav1.TypeMeta{APIVersion: apiv1alpha3.GroupVersion.String(), Kind: agentTemplateKind}, + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "researcher"}, + Spec: apiv1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default"}}, + } + resource, err := structuredobject.FromGo(template, apiv1alpha3.GroupVersion.String(), agentTemplateKind, 0) + require.NoError(t, err) + return &apiv1alpha1.AgentTemplate{ + Ref: &apiv1alpha1.ResourceReference{Namespace: template.Namespace, Name: template.Name}, + Resource: resource, + } +} + +type recordingAgentTemplateClient struct { + template *apiv1alpha1.AgentTemplate + createErr error + createRequest *apiv1alpha1.CreateAgentTemplateRequest + updateRequest *apiv1alpha1.UpdateAgentTemplateRequest +} + +func (c *recordingAgentTemplateClient) CreateAgentTemplate(_ context.Context, request *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) { + c.createRequest = request + if c.createErr != nil { + return nil, c.createErr + } + return &apiv1alpha1.CreateAgentTemplateResponse{AgentTemplate: c.template}, nil +} + +func (c *recordingAgentTemplateClient) UpdateAgentTemplate(_ context.Context, request *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) { + c.updateRequest = request + return &apiv1alpha1.UpdateAgentTemplateResponse{AgentTemplate: c.template}, nil +} diff --git a/go/core/cli/root.go b/go/core/cli/root.go index 8803cdfe1..e4eed85ee 100644 --- a/go/core/cli/root.go +++ b/go/core/cli/root.go @@ -39,6 +39,7 @@ func Root() *cobra.Command { getCmd, createCmd, deleteCmd, + commands.NewApplyAgentTemplateCmd(), agentinstancecli.NewInvokeCmd(), commands.NewInstallCmd(), commands.NewUninstallCmd(), diff --git a/go/core/cli/root_test.go b/go/core/cli/root_test.go index d2012bc69..f3d6b6119 100644 --- a/go/core/cli/root_test.go +++ b/go/core/cli/root_test.go @@ -124,6 +124,10 @@ func TestRootCommandV2CatalogAndLifecycleContract(t *testing.T) { require.NoError(t, err) assert.Equal(t, "agent-instance ID", deleteInstanceCmd.Use) + applyCmd, _, err := rootCmd.Find([]string{"apply"}) + require.NoError(t, err) + assert.Equal(t, "apply -f FILE", applyCmd.Use) + assert.NotNil(t, applyCmd.Flags().Lookup("file")) for _, command := range []string{"suspend", "resume"} { _, _, err := rootCmd.Find([]string{command, "agent-instance"}) assert.Error(t, err, "%s must not be exposed by the CLI", command) @@ -140,8 +144,14 @@ func TestRootCommandRemovesLegacyPaths(t *testing.T) { for _, command := range []string{"deploy", "init", "build", "run", "add-mcp"} { assert.NotContains(t, rootCommands, command) } + assert.NotContains(t, rootCommands, "update") assert.Contains(t, rootCommands, "mcp") + createCmd, _, err := rootCmd.Find([]string{"create"}) + require.NoError(t, err) + assert.Len(t, createCmd.Commands(), 1) + assert.Equal(t, "agent-instance", createCmd.Commands()[0].Name()) + getCmd, _, err := rootCmd.Find([]string{"get"}) require.NoError(t, err) getCommands := make([]string, 0, len(getCmd.Commands())) @@ -173,6 +183,7 @@ func TestRootCommandOutputFormatReachesResourceCommands(t *testing.T) { "get agent-instance": {"get", "agent-instance"}, "get agent-template": {"get", "agent-template"}, "create agent-instance": {"create", "agent-instance", "--harness", "kagent", "--agent-template", "example"}, + "apply agent-template": {"apply", "--file", "template.yaml"}, "delete agent-instance": {"delete", "agent-instance", "8bd650a8-9775-488f-8bc1-0d52bf7bdcab"}, "invoke": {"invoke", "--agent-instance", "8bd650a8-9775-488f-8bc1-0d52bf7bdcab", "--task", "hello"}, } { diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index ce7cb8e69..807508edd 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -2,6 +2,7 @@ package grpcserver import ( "context" + "maps" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/structuredobject" @@ -86,6 +87,7 @@ func (s *agentTemplateServer) UpdateAgentTemplate(ctx context.Context, request * return nil, err } existing.Spec = *incoming.Spec.DeepCopy() + existing.Labels = maps.Clone(incoming.Labels) result, err := s.service.SaveUpdate(ctx, existing) if err != nil { return nil, err diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 8c9c13f3f..f7acc2fce 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -119,9 +119,11 @@ func TestAgentTemplateServiceGeneratedClient(t *testing.T) { ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "template-user")) ref := &apiv1alpha1.ResourceReference{Namespace: "team", Name: "a-created"} + createdTemplate := testAgentTemplate("team", "a-created", "gpt") + createdTemplate.Labels = map[string]string{"runtime": "kagent"} created, err := client.CreateAgentTemplate(ctx, &apiv1alpha1.CreateAgentTemplateRequest{ Ref: ref, - Resource: structured(t, testAgentTemplate("team", "a-created", "gpt"), agentTemplateKind), + Resource: structured(t, createdTemplate, agentTemplateKind), }) if err != nil { t.Fatalf("CreateAgentTemplate() error = %v", err) @@ -147,9 +149,11 @@ func TestAgentTemplateServiceGeneratedClient(t *testing.T) { t.Fatalf("GetAgentTemplate() description = %q", got.GetAgentTemplate().GetDescription()) } + updatedTemplate := testAgentTemplate("team", "a-created", "claude") + updatedTemplate.Labels = map[string]string{"runtime": "codex"} updated, err := client.UpdateAgentTemplate(ctx, &apiv1alpha1.UpdateAgentTemplateRequest{ Ref: ref, - Resource: structured(t, testAgentTemplate("team", "a-created", "claude"), agentTemplateKind), + Resource: structured(t, updatedTemplate, agentTemplateKind), }) if err != nil { t.Fatalf("UpdateAgentTemplate() error = %v", err) @@ -157,6 +161,13 @@ func TestAgentTemplateServiceGeneratedClient(t *testing.T) { if updated.GetAgentTemplate().GetModelConfigRef().GetName() != "claude" { t.Fatalf("UpdateAgentTemplate() modelConfigRef = %+v", updated.GetAgentTemplate().GetModelConfigRef()) } + updatedResource := &v1alpha3.AgentTemplate{} + if err := structuredobject.ToGo(updated.GetAgentTemplate().GetResource(), agentTemplateKind, updatedResource, DefaultMaxMessageSize); err != nil { + t.Fatalf("decode UpdateAgentTemplate() resource: %v", err) + } + if got := updatedResource.Labels["runtime"]; got != "codex" { + t.Fatalf("UpdateAgentTemplate() label runtime = %q, want codex", got) + } listed, err := client.ListAgentTemplates(ctx, &apiv1alpha1.ListAgentTemplatesRequest{Namespace: "team"}) if err != nil { diff --git a/go/core/test/e2e/cli_catalog_lifecycle_test.go b/go/core/test/e2e/cli_catalog_lifecycle_test.go index 8fbd74c8f..82b27aaab 100644 --- a/go/core/test/e2e/cli_catalog_lifecycle_test.go +++ b/go/core/test/e2e/cli_catalog_lifecycle_test.go @@ -4,14 +4,18 @@ import ( "bytes" "context" "encoding/json" + "fmt" "os" "os/exec" + "path/filepath" "strings" "testing" "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + v1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "google.golang.org/protobuf/encoding/protojson" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/clientcmd" ) @@ -90,6 +94,73 @@ func TestE2ECLIAgentTemplateCatalogAndInstanceLifecycle(t *testing.T) { } } +func TestE2ECLIAgentTemplateLifecycle(t *testing.T) { + target := interactionTarget(t) + kube := interactionKubeClient(t) + model := createInteractionModel(t, kube, startInteractionMock(t), nil) + templateName := "cli-lifecycle-" + uuid.NewString() + manifestPath := filepath.Join(t.TempDir(), "agent-template.yaml") + binary := kagentCLI(t) + baseArgs := []string{ + "--grpc-url", target, + "--grpc-tls=false", + "--namespace", "kagent", + "--user-id", "e2e", + } + run := func(args ...string) string { + return runKagentCLI(t, t.Context(), binary, append(append([]string{}, baseArgs...), args...)...) + } + writeManifest := func(systemPrompt, runtime string) { + t.Helper() + manifest := fmt.Sprintf(`apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: %s + labels: + kagent.dev/e2e-runtime: %s +spec: + modelConfig: + name: %s + systemPrompt: %q +`, templateName, runtime, model.Name, systemPrompt) + if err := os.WriteFile(manifestPath, []byte(manifest), 0o600); err != nil { + t.Fatalf("write AgentTemplate manifest: %v", err) + } + } + assertTemplate := func(wantPrompt, wantRuntime string) { + t.Helper() + template := &v1alpha3.AgentTemplate{} + if err := kube.Get(t.Context(), types.NamespacedName{Namespace: "kagent", Name: templateName}, template); err != nil { + t.Fatalf("get AgentTemplate %q: %v", templateName, err) + } + if template.Spec.SystemPrompt != wantPrompt { + t.Fatalf("AgentTemplate systemPrompt = %q, want %q", template.Spec.SystemPrompt, wantPrompt) + } + if got := template.Labels["kagent.dev/e2e-runtime"]; got != wantRuntime { + t.Fatalf("AgentTemplate runtime label = %q, want %q", got, wantRuntime) + } + } + t.Cleanup(func() { + template := &v1alpha3.AgentTemplate{} + if err := kube.Get(context.Background(), types.NamespacedName{Namespace: "kagent", Name: templateName}, template); err == nil { + if err := kube.Delete(context.Background(), template); err != nil { + t.Errorf("cleanup AgentTemplate %q: %v", templateName, err) + } + } + }) + + writeManifest("applied through the CLI", "kagent") + applied := run("--output-format", "json", "apply", "-f", manifestPath) + if !json.Valid([]byte(applied)) || !strings.Contains(applied, `"name":"`+templateName+`"`) { + t.Fatalf("apply AgentTemplate stdout = %q, want JSON for %q", applied, templateName) + } + assertTemplate("applied through the CLI", "kagent") + + writeManifest("reapplied through the CLI", "codex") + run("apply", "-f", manifestPath) + assertTemplate("reapplied through the CLI", "codex") +} + func TestE2ECLIAgentInstanceDiscoveryAndInvoke(t *testing.T) { t.Parallel() target := interactionTarget(t)