From a8d053460b37fbac51267762a241a114579ed09d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 26 Sep 2025 14:21:47 +0000 Subject: [PATCH 1/3] feat: `prompt` and `app_id` fields to `coder_ai_task` Closes https://github.com/coder/internal/issues/977 Adds one required field `app_id`, one read-only computed field `prompt`, as well as deprecates the `sidebar_app` field. Also updates the `id` field to inherit the value from `CODER_TASK_ID` env variable --- docs/resources/ai_task.md | 6 ++- provider/ai_task.go | 61 ++++++++++++++++++--- provider/ai_task_test.go | 108 ++++++++++++++++++++++++++++++++------ provider/provider.go | 2 +- 4 files changed, 150 insertions(+), 27 deletions(-) diff --git a/docs/resources/ai_task.md b/docs/resources/ai_task.md index 1922ef59..678752f3 100644 --- a/docs/resources/ai_task.md +++ b/docs/resources/ai_task.md @@ -15,13 +15,15 @@ Use this resource to define Coder tasks. ## Schema -### Required +### Optional -- `sidebar_app` (Block Set, Min: 1, Max: 1) The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi. (see [below for nested schema](#nestedblock--sidebar_app)) +- `app_id` (String) The ID of the coder_app resource that provides the AI interface for this task. +- `sidebar_app` (Block Set, Max: 1, Deprecated) The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi. (see [below for nested schema](#nestedblock--sidebar_app)) ### Read-Only - `id` (String) A unique identifier for this resource. +- `prompt` (String) The prompt text provided to the task by Coder. ### Nested Schema for `sidebar_app` diff --git a/provider/ai_task.go b/provider/ai_task.go index 76b19f3c..3e4a13e4 100644 --- a/provider/ai_task.go +++ b/provider/ai_task.go @@ -2,6 +2,7 @@ package provider import ( "context" + "os" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -21,13 +22,43 @@ type AITaskSidebarApp struct { // TaskPromptParameterName is the name of the parameter which is *required* to be defined when a coder_ai_task is used. const TaskPromptParameterName = "AI Prompt" -func aiTask() *schema.Resource { +func aiTaskResource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, Description: "Use this resource to define Coder tasks.", CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics { - resourceData.SetId(uuid.NewString()) + if idStr := os.Getenv("CODER_TASK_ID"); idStr != "" { + resourceData.SetId(idStr) + } else { + resourceData.SetId(uuid.NewString()) + } + + if prompt := os.Getenv("CODER_TASK_PROMPT"); prompt != "" { + resourceData.Set("prompt", prompt) + } else { + resourceData.Set("prompt", "default") + } + + var ( + appID = resourceData.Get("app_id").(string) + sidebarAppSet = resourceData.Get("sidebar_app").(*schema.Set) + ) + + if appID == "" && sidebarAppSet.Len() > 0 { + sidebarApps := sidebarAppSet.List() + sidebarApp := sidebarApps[0].(map[string]any) + + if id, ok := sidebarApp["id"].(string); ok && id != "" { + appID = id + resourceData.Set("app_id", id) + } + } + + if appID == "" { + return diag.Errorf("'app_id' must be set") + } + return nil }, ReadContext: schema.NoopContext, @@ -39,11 +70,13 @@ func aiTask() *schema.Resource { Computed: true, }, "sidebar_app": { - Type: schema.TypeSet, - Description: "The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi.", - ForceNew: true, - Required: true, - MaxItems: 1, + Type: schema.TypeSet, + Description: "The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi.", + Deprecated: "This field has been deprecated in favor of the `app_id` field.", + ForceNew: true, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"app_id"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -56,6 +89,20 @@ func aiTask() *schema.Resource { }, }, }, + "prompt": { + Type: schema.TypeString, + Description: "The prompt text provided to the task by Coder.", + Computed: true, + }, + "app_id": { + Type: schema.TypeString, + Description: "The ID of the coder_app resource that provides the AI interface for this task.", + ForceNew: true, + Optional: true, + Computed: true, + ValidateFunc: validation.IsUUID, + ConflictsWith: []string{"sidebar_app"}, + }, }, } } diff --git a/provider/ai_task_test.go b/provider/ai_task_test.go index 5f7a8a49..f96e12a0 100644 --- a/provider/ai_task_test.go +++ b/provider/ai_task_test.go @@ -10,9 +10,48 @@ import ( ) func TestAITask(t *testing.T) { - t.Parallel() - t.Run("OK", func(t *testing.T) { + t.Setenv("CODER_TASK_ID", "7d8d4c2e-fb57-44f9-a183-22509819c2e7") + t.Setenv("CODER_TASK_PROMPT", "some task prompt") + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_ai_task" "test" { + app_id = "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc" + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + resource := state.Modules[0].Resources["coder_ai_task.test"] + require.NotNil(t, resource) + for _, key := range []string{ + "id", + "prompt", + "app_id", + } { + value := resource.Primary.Attributes[key] + require.NotNil(t, value) + require.Greater(t, len(value), 0) + } + + taskID := resource.Primary.Attributes["id"] + require.Equal(t, "7d8d4c2e-fb57-44f9-a183-22509819c2e7", taskID) + + taskPrompt := resource.Primary.Attributes["prompt"] + require.Equal(t, "some task prompt", taskPrompt) + + return nil + }, + }}, + }) + }) + + t.Run("InvalidAppID", func(t *testing.T) { t.Parallel() resource.Test(t, resource.TestCase{ @@ -22,21 +61,28 @@ func TestAITask(t *testing.T) { Config: ` provider "coder" { } - resource "coder_agent" "dev" { - os = "linux" - arch = "amd64" + resource "coder_ai_task" "test" { + app_id = "not-a-uuid" } - resource "coder_app" "code-server" { - agent_id = coder_agent.dev.id - slug = "code-server" - display_name = "code-server" - icon = "builtin:vim" - url = "http://localhost:13337" - open_in = "slim-window" + `, + ExpectError: regexp.MustCompile(`expected "app_id" to be a valid UUID`), + }}, + }) + }) + + t.Run("DeprecatedSidebarApp", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { } resource "coder_ai_task" "test" { sidebar_app { - id = coder_app.code-server.id + id = "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc" } } `, @@ -44,22 +90,31 @@ func TestAITask(t *testing.T) { require.Len(t, state.Modules, 1) resource := state.Modules[0].Resources["coder_ai_task.test"] require.NotNil(t, resource) + for _, key := range []string{ "id", - "sidebar_app.#", + "prompt", + "app_id", } { value := resource.Primary.Attributes[key] require.NotNil(t, value) require.Greater(t, len(value), 0) } + require.Equal(t, "1", resource.Primary.Attributes["sidebar_app.#"]) + sidebarAppID := resource.Primary.Attributes["sidebar_app.0.id"] + require.Equal(t, "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc", sidebarAppID) + + actualAppID := resource.Primary.Attributes["app_id"] + require.Equal(t, "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc", actualAppID) + return nil }, }}, }) }) - t.Run("InvalidSidebarAppID", func(t *testing.T) { + t.Run("ConflictingFields", func(t *testing.T) { t.Parallel() resource.Test(t, resource.TestCase{ @@ -70,12 +125,31 @@ func TestAITask(t *testing.T) { provider "coder" { } resource "coder_ai_task" "test" { + app_id = "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc" sidebar_app { - id = "not-a-uuid" + id = "9a3ff7b4-4b3f-48c6-8d3a-a8118ac921fc" } } `, - ExpectError: regexp.MustCompile(`expected "sidebar_app.0.id" to be a valid UUID`), + ExpectError: regexp.MustCompile(`"app_id": conflicts with sidebar_app`), + }}, + }) + }) + + t.Run("NoAppID", func(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_ai_task" "test" { + } + `, + ExpectError: regexp.MustCompile(`'app_id' must be set`), }}, }) }) diff --git a/provider/provider.go b/provider/provider.go index 5a2f1972..2b6409ba 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -68,7 +68,7 @@ func New() *schema.Provider { ResourcesMap: map[string]*schema.Resource{ "coder_agent": agentResource(), "coder_agent_instance": agentInstanceResource(), - "coder_ai_task": aiTask(), + "coder_ai_task": aiTaskResource(), "coder_app": appResource(), "coder_metadata": metadataResource(), "coder_script": scriptResource(), From 1b674c85ddc9f8e8c27b191f84eb40b8890992d7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 29 Sep 2025 14:04:25 +0000 Subject: [PATCH 2/3] chore: make `CODER_TASK_ID` env var required --- provider/ai_task.go | 3 +-- provider/ai_task_test.go | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/provider/ai_task.go b/provider/ai_task.go index 3e4a13e4..64bc8a29 100644 --- a/provider/ai_task.go +++ b/provider/ai_task.go @@ -4,7 +4,6 @@ import ( "context" "os" - "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -31,7 +30,7 @@ func aiTaskResource() *schema.Resource { if idStr := os.Getenv("CODER_TASK_ID"); idStr != "" { resourceData.SetId(idStr) } else { - resourceData.SetId(uuid.NewString()) + return diag.Errorf("CODER_TASK_ID must be set") } if prompt := os.Getenv("CODER_TASK_PROMPT"); prompt != "" { diff --git a/provider/ai_task_test.go b/provider/ai_task_test.go index f96e12a0..d7357646 100644 --- a/provider/ai_task_test.go +++ b/provider/ai_task_test.go @@ -10,9 +10,11 @@ import ( ) func TestAITask(t *testing.T) { + t.Setenv("CODER_TASK_ID", "7d8d4c2e-fb57-44f9-a183-22509819c2e7") + t.Setenv("CODER_TASK_PROMPT", "some task prompt") + t.Run("OK", func(t *testing.T) { - t.Setenv("CODER_TASK_ID", "7d8d4c2e-fb57-44f9-a183-22509819c2e7") - t.Setenv("CODER_TASK_PROMPT", "some task prompt") + t.Parallel() resource.Test(t, resource.TestCase{ ProviderFactories: coderFactory(), From 347e65970269b07a5cb8ad00daa7a3b550126301 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Oct 2025 11:11:25 +0000 Subject: [PATCH 3/3] chore: consistency --- docs/resources/ai_task.md | 2 +- provider/ai_task.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/resources/ai_task.md b/docs/resources/ai_task.md index 678752f3..bd5cfcdd 100644 --- a/docs/resources/ai_task.md +++ b/docs/resources/ai_task.md @@ -17,7 +17,7 @@ Use this resource to define Coder tasks. ### Optional -- `app_id` (String) The ID of the coder_app resource that provides the AI interface for this task. +- `app_id` (String) The ID of the `coder_app` resource that provides the AI interface for this task. - `sidebar_app` (Block Set, Max: 1, Deprecated) The coder_app to display in the sidebar. Usually a chat interface with the AI agent running in the workspace, like https://github.com/coder/agentapi. (see [below for nested schema](#nestedblock--sidebar_app)) ### Read-Only diff --git a/provider/ai_task.go b/provider/ai_task.go index 64bc8a29..520eeb4a 100644 --- a/provider/ai_task.go +++ b/provider/ai_task.go @@ -95,7 +95,7 @@ func aiTaskResource() *schema.Resource { }, "app_id": { Type: schema.TypeString, - Description: "The ID of the coder_app resource that provides the AI interface for this task.", + Description: "The ID of the `coder_app` resource that provides the AI interface for this task.", ForceNew: true, Optional: true, Computed: true,