Skip to content

Commit 4114bae

Browse files
committed
test: add session/sandbox lifecycle e2e tests for issue #103
Signed-off-by: Jagjeevan Kashid <jagjeevandev97@gmail.com>
1 parent 8ebae9b commit 4114bae

2 files changed

Lines changed: 383 additions & 0 deletions

File tree

test/e2e/echo_agent_short_ttl.yaml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
apiVersion: runtime.agentcube.volcano.sh/v1alpha1
2+
kind: AgentRuntime
3+
metadata:
4+
name: echo-agent-short-ttl
5+
namespace: agentcube
6+
spec:
7+
targetPort:
8+
- pathPrefix: "/echo"
9+
port: 8080
10+
protocol: "HTTP"
11+
podTemplate:
12+
labels:
13+
app: echo-agent-short-ttl
14+
test: e2e
15+
spec:
16+
containers:
17+
- name: echo-server
18+
image: python:3.9-slim
19+
ports:
20+
- containerPort: 8080
21+
protocol: TCP
22+
readinessProbe:
23+
httpGet:
24+
path: /echo
25+
port: 8080
26+
initialDelaySeconds: 5
27+
periodSeconds: 5
28+
timeoutSeconds: 3
29+
successThreshold: 1
30+
failureThreshold: 3
31+
livenessProbe:
32+
httpGet:
33+
path: /echo
34+
port: 8080
35+
initialDelaySeconds: 10
36+
periodSeconds: 10
37+
timeoutSeconds: 5
38+
successThreshold: 1
39+
failureThreshold: 3
40+
command: ["python3", "-c"]
41+
args:
42+
- |
43+
import http.server
44+
import socketserver
45+
import json
46+
import sys
47+
48+
class EchoHandler(http.server.BaseHTTPRequestHandler):
49+
def do_POST(self):
50+
if self.path.startswith('/echo'):
51+
try:
52+
content_length = int(self.headers['Content-Length'])
53+
post_data = self.rfile.read(content_length)
54+
request_data = json.loads(post_data.decode('utf-8'))
55+
input_text = request_data.get('input', '')
56+
57+
response = {
58+
'output': f'echo: {input_text}',
59+
'metadata': {
60+
'echoed': True,
61+
'original_input': input_text
62+
}
63+
}
64+
65+
self.send_response(200)
66+
self.send_header('Content-Type', 'application/json')
67+
self.end_headers()
68+
self.wfile.write(json.dumps(response).encode('utf-8'))
69+
except Exception as e:
70+
self.send_response(400)
71+
self.send_header('Content-Type', 'application/json')
72+
self.end_headers()
73+
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
74+
else:
75+
self.send_response(404)
76+
self.send_header('Content-Type', 'application/json')
77+
self.end_headers()
78+
self.wfile.write(b'{"error": "Not found"}')
79+
80+
def do_GET(self):
81+
if self.path == '/echo':
82+
# Health check for livenessProbe and readinessProbe
83+
self.send_response(200)
84+
self.send_header('Content-Type', 'application/json')
85+
self.end_headers()
86+
self.wfile.write(b'{"status": "healthy"}')
87+
else:
88+
self.send_response(404)
89+
self.send_header('Content-Type', 'application/json')
90+
self.end_headers()
91+
self.wfile.write(b'{"error": "Not found"}')
92+
93+
def log_message(self, format, *args):
94+
# Suppress server logs
95+
pass
96+
97+
with socketserver.TCPServer(("0.0.0.0", 8080), EchoHandler) as httpd:
98+
print("Echo server started on port 8080")
99+
httpd.serve_forever()
100+
# Very short idle TTL – used by TestSessionSandboxLifecycleTTL (A3) to verify
101+
# that expired sessions are properly invalidated by the garbage collector.
102+
sessionTimeout: "30s"
103+
maxSessionDuration: "8h"
104+
status: {}

test/e2e/session_lifecycle_test.go

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/*
2+
Copyright The Volcano Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package e2e contains end-to-end tests for AgentCube session and sandbox lifecycle.
18+
//
19+
// This file covers the net-new lifecycle scenarios from GitHub issue #103 that
20+
// are not already addressed by the existing e2e_test.go:
21+
//
22+
// - A1: AgentRuntime session auto-creation and reuse via x-agentcube-session-id header
23+
// - B1: CodeInterpreter session auto-creation via the Router (header round-trip)
24+
// - B2: Stateful CodeInterpreter session (variable persists across multiple calls)
25+
//
26+
// Scenarios A2, A3, and B3 are covered by existing tests:
27+
// - A2 -> TestAgentRuntimeErrorHandling (e2e_test.go)
28+
// - A3 -> TestAgentRuntimeSessionTTL (e2e_test.go)
29+
// - B3 -> TestCodeInterpreterFileOperations / "download file" (e2e_test.go)
30+
package e2e
31+
32+
import (
33+
"bytes"
34+
"encoding/json"
35+
"fmt"
36+
"io"
37+
"net/http"
38+
"strings"
39+
"testing"
40+
"time"
41+
)
42+
43+
// ============================================================
44+
// A1 – AgentRuntime: session auto-creation and reuse
45+
// ============================================================
46+
47+
// TestAgentRuntimeSessionCreationAndReuse verifies that:
48+
// 1. A POST without x-agentcube-session-id returns HTTP 200 AND a non-empty
49+
// x-agentcube-session-id header (auto-create path).
50+
// 2. A second POST with that session ID returns HTTP 200 (session reuse path)
51+
// and the same session ID echoed back.
52+
func TestAgentRuntimeSessionCreationAndReuse(t *testing.T) {
53+
env := newTestEnv(t)
54+
55+
namespace := agentcubeNamespace
56+
runtimeName := "echo-agent"
57+
58+
// --- Step 1: First call – no session ID provided ---
59+
req1 := &AgentInvokeRequest{
60+
Input: "hello from A1 step 1",
61+
Metadata: map[string]interface{}{
62+
"test": "session_creation",
63+
},
64+
}
65+
66+
resp1, sessionID, err := env.invokeAgentRuntime(namespace, runtimeName, "", req1)
67+
if err != nil {
68+
t.Fatalf("A1 step 1: unexpected error on first invoke: %v", err)
69+
}
70+
if resp1 == nil {
71+
t.Fatal("A1 step 1: response is nil")
72+
}
73+
if sessionID == "" {
74+
t.Fatal("A1 step 1: expected x-agentcube-session-id header in response, got empty string")
75+
}
76+
t.Logf("A1 step 1: session auto-created, session_id=%s", sessionID)
77+
78+
// The echo agent prefixes output with "echo: "
79+
expectedOutput1 := "echo: hello from A1 step 1"
80+
if resp1.Output != expectedOutput1 {
81+
t.Errorf("A1 step 1: expected output %q, got %q", expectedOutput1, resp1.Output)
82+
}
83+
84+
// --- Step 2: Second call – reuse the session ID ---
85+
req2 := &AgentInvokeRequest{
86+
Input: "hello from A1 step 2",
87+
Metadata: map[string]interface{}{
88+
"test": "session_reuse",
89+
},
90+
}
91+
92+
resp2, sessionID2, err := env.invokeAgentRuntime(namespace, runtimeName, sessionID, req2)
93+
if err != nil {
94+
t.Fatalf("A1 step 2: unexpected error on second invoke with session_id=%s: %v", sessionID, err)
95+
}
96+
if resp2 == nil {
97+
t.Fatal("A1 step 2: response is nil")
98+
}
99+
100+
// The returned session ID must be the same (or at least non-empty).
101+
if sessionID2 == "" {
102+
t.Error("A1 step 2: expected x-agentcube-session-id header in response, got empty string")
103+
}
104+
if sessionID2 != sessionID {
105+
t.Errorf("A1 step 2: expected session ID to remain %q, got %q", sessionID, sessionID2)
106+
}
107+
108+
expectedOutput2 := "echo: hello from A1 step 2"
109+
if resp2.Output != expectedOutput2 {
110+
t.Errorf("A1 step 2: expected output %q, got %q", expectedOutput2, resp2.Output)
111+
}
112+
113+
t.Logf("A1: session reuse verified – session_id=%s, output=%q", sessionID, resp2.Output)
114+
}
115+
116+
// ============================================================
117+
// B1 – CodeInterpreter: session auto-creation via Router
118+
// ============================================================
119+
120+
// TestCodeInterpreterSessionAutoCreation verifies that a POST to the Router
121+
// code-interpreter endpoint *without* an x-agentcube-session-id header:
122+
// 1. Returns HTTP 200.
123+
// 2. Produces the expected stdout (e.g. "2\n" for print(1+1)).
124+
// 3. Sets the x-agentcube-session-id response header.
125+
func TestCodeInterpreterSessionAutoCreation(t *testing.T) {
126+
env := newTestEnv(t)
127+
128+
namespace := agentcubeNamespace
129+
name := e2eCodeInterpreterName
130+
131+
// Run a trivial Python expression through the interpreter.
132+
req := &CodeInterpreterExecuteRequest{
133+
Command: []string{"python3", "-c", "print(1+1)"},
134+
}
135+
136+
resp, sessionID, err := env.invokeCodeInterpreterWithHeader(namespace, name, "", req)
137+
if err != nil {
138+
t.Fatalf("B1: unexpected error: %v", err)
139+
}
140+
if resp == nil {
141+
t.Fatal("B1: response is nil")
142+
}
143+
144+
// Assert: correct stdout.
145+
expectedStdout := "2\n"
146+
if resp.Stdout != expectedStdout {
147+
t.Errorf("B1: expected stdout %q, got %q", expectedStdout, resp.Stdout)
148+
}
149+
if resp.ExitCode != 0 {
150+
t.Errorf("B1: expected exit code 0, got %d (stderr: %s)", resp.ExitCode, resp.Stderr)
151+
}
152+
153+
// Assert: session ID header is present.
154+
if sessionID == "" {
155+
t.Error("B1: expected x-agentcube-session-id header in response, got empty string")
156+
} else {
157+
t.Logf("B1: session auto-created by Router, session_id=%s", sessionID)
158+
}
159+
}
160+
161+
// ============================================================
162+
// B2 – CodeInterpreter: stateful multi-step session
163+
// ============================================================
164+
165+
// TestCodeInterpreterStatefulSession verifies that the CodeInterpreter preserves
166+
// state across multiple calls within the same session.
167+
//
168+
// Because each command runs in a fresh sub-process, we write state to a file in
169+
// the shared workspace on step 1 and read it back on step 2.
170+
func TestCodeInterpreterStatefulSession(t *testing.T) {
171+
env := newTestEnv(t)
172+
173+
namespace := agentcubeNamespace
174+
name := e2eCodeInterpreterName
175+
176+
// Create a session explicitly so we control the session ID.
177+
sessionID, err := env.createCodeInterpreterSession(namespace, name)
178+
if err != nil {
179+
t.Fatalf("B2: failed to create code interpreter session: %v", err)
180+
}
181+
t.Cleanup(func() {
182+
_ = env.deleteCodeInterpreterSession(sessionID)
183+
})
184+
t.Logf("B2: session created, session_id=%s", sessionID)
185+
186+
// Step 1: Write state to a file in the workspace.
187+
step1 := &CodeInterpreterExecuteRequest{
188+
Command: []string{"python3", "-c", "open('_state.py','w').write('x = 10\\n')"},
189+
}
190+
resp1, err := env.invokeCodeInterpreter(namespace, name, sessionID, step1)
191+
if err != nil {
192+
t.Fatalf("B2 step 1 (write state): unexpected error: %v", err)
193+
}
194+
if resp1.ExitCode != 0 {
195+
t.Fatalf("B2 step 1 (write state): expected exit code 0, got %d (stderr: %s)",
196+
resp1.ExitCode, resp1.Stderr)
197+
}
198+
t.Log("B2 step 1: state written to _state.py")
199+
200+
// Step 2: Read the file and print x.
201+
step2 := &CodeInterpreterExecuteRequest{
202+
Command: []string{"python3", "-c",
203+
"exec(open('_state.py').read()); print(x)"},
204+
}
205+
resp2, err := env.invokeCodeInterpreter(namespace, name, sessionID, step2)
206+
if err != nil {
207+
t.Fatalf("B2 step 2 (read state): unexpected error: %v", err)
208+
}
209+
if resp2.ExitCode != 0 {
210+
t.Fatalf("B2 step 2 (read state): expected exit code 0, got %d (stderr: %s)",
211+
resp2.ExitCode, resp2.Stderr)
212+
}
213+
214+
// Assert: printed value is "10".
215+
expectedOutput := "10\n"
216+
if resp2.Stdout != expectedOutput {
217+
t.Errorf("B2 step 2: expected stdout %q, got %q (state persisted via file in shared workspace)",
218+
expectedOutput, resp2.Stdout)
219+
}
220+
t.Logf("B2: stateful session verified – printed x=%q", strings.TrimSpace(resp2.Stdout))
221+
}
222+
223+
// ============================================================
224+
// Helpers used only in this file
225+
// ============================================================
226+
227+
// invokeCodeInterpreterWithHeader is like invokeCodeInterpreter but also returns
228+
// the x-agentcube-session-id response header so B1 can assert on it.
229+
func (e *testEnv) invokeCodeInterpreterWithHeader(
230+
namespace, name, sessionID string,
231+
req *CodeInterpreterExecuteRequest,
232+
) (*CodeInterpreterExecuteResponse, string, error) {
233+
jsonData, err := json.Marshal(req)
234+
if err != nil {
235+
return nil, "", fmt.Errorf("failed to marshal request: %w", err)
236+
}
237+
238+
rawURL := fmt.Sprintf("%s/v1/namespaces/%s/code-interpreters/%s/invocations/api/execute",
239+
e.routerURL, namespace, name)
240+
241+
httpReq, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewBuffer(jsonData))
242+
if err != nil {
243+
return nil, "", fmt.Errorf("failed to create request: %w", err)
244+
}
245+
246+
httpReq.Header.Set("Content-Type", "application/json")
247+
if e.authToken != "" {
248+
httpReq.Header.Set("Authorization", "Bearer "+e.authToken)
249+
}
250+
if sessionID != "" {
251+
httpReq.Header.Set("x-agentcube-session-id", sessionID)
252+
}
253+
254+
client := &http.Client{Timeout: 60 * time.Second}
255+
httpResp, err := client.Do(httpReq)
256+
if err != nil {
257+
return nil, "", fmt.Errorf("failed to send request: %w", err)
258+
}
259+
defer httpResp.Body.Close()
260+
261+
respSessionID := httpResp.Header.Get("x-agentcube-session-id")
262+
263+
body, err := io.ReadAll(httpResp.Body)
264+
if err != nil {
265+
return nil, respSessionID, fmt.Errorf("failed to read response body: %w", err)
266+
}
267+
268+
if httpResp.StatusCode != http.StatusOK {
269+
return nil, respSessionID, fmt.Errorf("request failed with status %d: %s",
270+
httpResp.StatusCode, string(body))
271+
}
272+
273+
var resp CodeInterpreterExecuteResponse
274+
if err := json.Unmarshal(body, &resp); err != nil {
275+
return nil, respSessionID, fmt.Errorf("failed to unmarshal response: %w", err)
276+
}
277+
278+
return &resp, respSessionID, nil
279+
}

0 commit comments

Comments
 (0)