@@ -2,22 +2,240 @@ package cli_test
22
33import (
44"context"
5+ "encoding/json"
56"net/http"
67"net/http/httptest"
8+ "slices"
9+ "strings"
710"sync"
811"testing"
12+ "time"
913
1014"github.com/google/uuid"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+ "golang.org/x/xerrors"
18+
19+ agentapisdk"github.com/coder/agentapi-sdk-go"
1120
1221"github.com/coder/coder/v2/agent"
1322"github.com/coder/coder/v2/agent/agenttest"
23+ "github.com/coder/coder/v2/cli/clitest"
1424"github.com/coder/coder/v2/coderd/coderdtest"
25+ "github.com/coder/coder/v2/coderd/util/ptr"
1526"github.com/coder/coder/v2/codersdk"
1627"github.com/coder/coder/v2/codersdk/agentsdk"
1728"github.com/coder/coder/v2/provisioner/echo"
1829"github.com/coder/coder/v2/provisionersdk/proto"
30+ "github.com/coder/coder/v2/testutil"
1931)
2032
33+ // This test performs an integration-style test for tasks functionality.
34+ //
35+ //nolint:tparallel // The sub-tests of this test must be run sequentially.
36+ func Test_Tasks (t * testing.T ) {
37+ t .Parallel ()
38+
39+ // Given: a template configured for tasks
40+ var (
41+ ctx = testutil .Context (t ,testutil .WaitLong )
42+ client = coderdtest .New (t ,& coderdtest.Options {IncludeProvisionerDaemon :true })
43+ owner = coderdtest .CreateFirstUser (t ,client )
44+ userClient ,_ = coderdtest .CreateAnotherUser (t ,client ,owner .OrganizationID )
45+ initMsg = agentapisdk.Message {
46+ Content :"test task input for " + t .Name (),
47+ Id :0 ,
48+ Role :"user" ,
49+ Time :time .Now ().UTC (),
50+ }
51+ authToken = uuid .NewString ()
52+ echoAgentAPI = startFakeAgentAPI (t ,fakeAgentAPIEcho (ctx ,t ,initMsg ,"hello" ))
53+ taskTpl = createAITaskTemplate (t ,client ,owner .OrganizationID ,withAgentToken (authToken ),withSidebarURL (echoAgentAPI .URL ()))
54+ taskName = strings .ReplaceAll (testutil .GetRandomName (t ),"_" ,"-" )
55+ )
56+
57+ //nolint:paralleltest // The sub-tests of this test must be run sequentially.
58+ for _ ,tc := range []struct {
59+ name string
60+ cmdArgs []string
61+ assertFn func (stdout string ,userClient * codersdk.Client )
62+ }{
63+ {
64+ name :"create task" ,
65+ cmdArgs : []string {"exp" ,"task" ,"create" ,"test task input for " + t .Name (),"--name" ,taskName ,"--template" ,taskTpl .Name },
66+ assertFn :func (stdout string ,userClient * codersdk.Client ) {
67+ require .Contains (t ,stdout ,taskName ,"task name should be in output" )
68+ },
69+ },
70+ {
71+ name :"list tasks after create" ,
72+ cmdArgs : []string {"exp" ,"task" ,"list" ,"--output" ,"json" },
73+ assertFn :func (stdout string ,userClient * codersdk.Client ) {
74+ var tasks []codersdk.Task
75+ err := json .NewDecoder (strings .NewReader (stdout )).Decode (& tasks )
76+ require .NoError (t ,err ,"list output should unmarshal properly" )
77+ require .Len (t ,tasks ,1 ,"expected one task" )
78+ require .Equal (t ,taskName ,tasks [0 ].Name ,"task name should match" )
79+ require .Equal (t ,initMsg .Content ,tasks [0 ].InitialPrompt ,"initial prompt should match" )
80+ require .True (t ,tasks [0 ].WorkspaceID .Valid ,"workspace should be created" )
81+ // For the next test, we need to wait for the workspace to be healthy
82+ ws := coderdtest .MustWorkspace (t ,userClient ,tasks [0 ].WorkspaceID .UUID )
83+ coderdtest .AwaitWorkspaceBuildJobCompleted (t ,client ,ws .LatestBuild .ID )
84+ agentClient := agentsdk .New (client .URL ,agentsdk .WithFixedToken (authToken ))
85+ _ = agenttest .New (t ,client .URL ,authToken ,func (o * agent.Options ) {
86+ o .Client = agentClient
87+ })
88+ coderdtest .NewWorkspaceAgentWaiter (t ,userClient ,tasks [0 ].WorkspaceID .UUID ).WithContext (ctx ).WaitFor (coderdtest .AgentsReady )
89+ },
90+ },
91+ {
92+ name :"get task status after create" ,
93+ cmdArgs : []string {"exp" ,"task" ,"status" ,taskName ,"--output" ,"json" },
94+ assertFn :func (stdout string ,userClient * codersdk.Client ) {
95+ var task codersdk.Task
96+ require .NoError (t ,json .NewDecoder (strings .NewReader (stdout )).Decode (& task ),"should unmarshal task status" )
97+ require .Equal (t ,task .Name ,taskName ,"task name should match" )
98+ // NOTE: task status changes type, this is so this test works with both old and new model
99+ require .Contains (t , []string {"active" ,"running" },string (task .Status ),"task should be active" )
100+ },
101+ },
102+ {
103+ name :"send task message" ,
104+ cmdArgs : []string {"exp" ,"task" ,"send" ,taskName ,"hello" },
105+ // Assertions for this happen in the fake agent API handler.
106+ },
107+ {
108+ name :"read task logs" ,
109+ cmdArgs : []string {"exp" ,"task" ,"logs" ,taskName ,"--output" ,"json" },
110+ assertFn :func (stdout string ,userClient * codersdk.Client ) {
111+ var logs []codersdk.TaskLogEntry
112+ require .NoError (t ,json .NewDecoder (strings .NewReader (stdout )).Decode (& logs ),"should unmarshal task logs" )
113+ require .Len (t ,logs ,3 ,"should have 3 logs" )
114+ require .Equal (t ,logs [0 ].Content ,initMsg .Content ,"first message should be the init message" )
115+ require .Equal (t ,logs [0 ].Type ,codersdk .TaskLogTypeInput ,"first message should be an input" )
116+ require .Equal (t ,logs [1 ].Content ,"hello" ,"second message should be the sent message" )
117+ require .Equal (t ,logs [1 ].Type ,codersdk .TaskLogTypeInput ,"second message should be an input" )
118+ require .Equal (t ,logs [2 ].Content ,"hello" ,"third message should be the echoed message" )
119+ require .Equal (t ,logs [2 ].Type ,codersdk .TaskLogTypeOutput ,"third message should be an output" )
120+ },
121+ },
122+ {
123+ name :"delete task" ,
124+ cmdArgs : []string {"exp" ,"task" ,"delete" ,taskName ,"--yes" },
125+ assertFn :func (stdout string ,userClient * codersdk.Client ) {
126+ // The task should eventually no longer show up in the list of tasks
127+ testutil .Eventually (ctx ,t ,func (ctx context.Context )bool {
128+ expClient := codersdk .NewExperimentalClient (userClient )
129+ tasks ,err := expClient .Tasks (ctx ,& codersdk.TasksFilter {})
130+ if ! assert .NoError (t ,err ) {
131+ return false
132+ }
133+ return slices .IndexFunc (tasks ,func (task codersdk.Task )bool {
134+ return task .Name == taskName
135+ })== - 1
136+ },testutil .IntervalMedium )
137+ },
138+ },
139+ } {
140+ t .Run (tc .name ,func (t * testing.T ) {
141+ var stdout strings.Builder
142+ inv ,root := clitest .New (t ,tc .cmdArgs ... )
143+ inv .Stdout = & stdout
144+ clitest .SetupConfig (t ,userClient ,root )
145+ require .NoError (t ,inv .WithContext (ctx ).Run ())
146+ if tc .assertFn != nil {
147+ tc .assertFn (stdout .String (),userClient )
148+ }
149+ })
150+ }
151+ }
152+
153+ func fakeAgentAPIEcho (ctx context.Context ,t testing.TB ,initMsg agentapisdk.Message ,want ... string )map [string ]http.HandlerFunc {
154+ t .Helper ()
155+ var mmu sync.RWMutex
156+ msgs := []agentapisdk.Message {initMsg }
157+ wantCpy := make ([]string ,len (want ))
158+ copy (wantCpy ,want )
159+ t .Cleanup (func () {
160+ mmu .Lock ()
161+ defer mmu .Unlock ()
162+ if ! t .Failed () {
163+ assert .Empty (t ,wantCpy ,"not all expected messages received: missing %v" ,wantCpy )
164+ }
165+ })
166+ writeAgentAPIError := func (w http.ResponseWriter ,err error ,status int ) {
167+ w .WriteHeader (status )
168+ _ = json .NewEncoder (w ).Encode (agentapisdk.ErrorModel {
169+ Errors :ptr .Ref ([]agentapisdk.ErrorDetail {
170+ {
171+ Message :ptr .Ref (err .Error ()),
172+ },
173+ }),
174+ })
175+ }
176+ return map [string ]http.HandlerFunc {
177+ "/status" :func (w http.ResponseWriter ,r * http.Request ) {
178+ w .Header ().Set ("Content-Type" ,"application/json" )
179+ _ = json .NewEncoder (w ).Encode (agentapisdk.GetStatusResponse {
180+ Status :"stable" ,
181+ })
182+ },
183+ "/messages" :func (w http.ResponseWriter ,r * http.Request ) {
184+ w .Header ().Set ("Content-Type" ,"application/json" )
185+ mmu .RLock ()
186+ defer mmu .RUnlock ()
187+ bs ,err := json .Marshal (agentapisdk.GetMessagesResponse {
188+ Messages :msgs ,
189+ })
190+ if err != nil {
191+ writeAgentAPIError (w ,err ,http .StatusBadRequest )
192+ return
193+ }
194+ _ ,_ = w .Write (bs )
195+ },
196+ "/message" :func (w http.ResponseWriter ,r * http.Request ) {
197+ mmu .Lock ()
198+ defer mmu .Unlock ()
199+ var params agentapisdk.PostMessageParams
200+ w .Header ().Set ("Content-Type" ,"application/json" )
201+ err := json .NewDecoder (r .Body ).Decode (& params )
202+ if ! assert .NoError (t ,err ,"decode message" ) {
203+ writeAgentAPIError (w ,err ,http .StatusBadRequest )
204+ return
205+ }
206+
207+ if len (wantCpy )== 0 {
208+ assert .Fail (t ,"unexpected message" ,"received message %v, but no more expected messages" ,params )
209+ writeAgentAPIError (w ,xerrors .New ("no more expected messages" ),http .StatusBadRequest )
210+ return
211+ }
212+ exp := wantCpy [0 ]
213+ wantCpy = wantCpy [1 :]
214+
215+ if ! assert .Equal (t ,exp ,params .Content ,"message content mismatch" ) {
216+ writeAgentAPIError (w ,xerrors .New ("unexpected message content: expected " + exp + ", got " + params .Content ),http .StatusBadRequest )
217+ return
218+ }
219+
220+ msgs = append (msgs , agentapisdk.Message {
221+ Id :int64 (len (msgs )+ 1 ),
222+ Content :params .Content ,
223+ Role :agentapisdk .RoleUser ,
224+ Time :time .Now ().UTC (),
225+ })
226+ msgs = append (msgs , agentapisdk.Message {
227+ Id :int64 (len (msgs )+ 1 ),
228+ Content :params .Content ,
229+ Role :agentapisdk .RoleAgent ,
230+ Time :time .Now ().UTC (),
231+ })
232+ assert .NoError (t ,json .NewEncoder (w ).Encode (agentapisdk.PostMessageResponse {
233+ Ok :true ,
234+ }))
235+ },
236+ }
237+ }
238+
21239// setupCLITaskTest creates a test workspace with an AI task template and agent,
22240// with a fake agent API configured with the provided set of handlers.
23241// Returns the user client and workspace.