- Implement secure config file location (/tmp/.yao/proxy.json) instead of user-visible /workspace/
- Add generic options map support for backend-specific parameters (e.g., thinking for Volcengine GLM-4.7)
- Add secrets support for passing sensitive env vars (e.g., GITHUB_TOKEN) to sandbox container
- Remove excessive debug logs, keep critical ones with log.Printf("[Sandbox]...")
- Fix test assertions for system prompt passing via CLI args instead of env var
- Update i18n messages for sandbox loading states
Co-authored-by: Cursor <cursoragent@cursor.com>
275 lines
8.8 KiB
Go
275 lines
8.8 KiB
Go
package claude
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
agentContext "github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/agent/output/message"
|
|
"github.com/yaoapp/yao/config"
|
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
|
"github.com/yaoapp/yao/test"
|
|
)
|
|
|
|
// TestE2ESkipClaudeCLI verifies that Claude CLI is skipped when no prompts/skills/mcp
|
|
// This is the "hook-only" mode where hooks take full control
|
|
func TestE2ESkipClaudeCLI(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test in short mode")
|
|
}
|
|
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Create options WITHOUT SystemPrompt, SkillsDir, or MCPConfig
|
|
// This should trigger the skip logic
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest", // Use alpine since we're not calling Claude CLI
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-e2e-skip-%d", time.Now().UnixNano()),
|
|
ConnectorHost: "",
|
|
ConnectorKey: "",
|
|
Model: "",
|
|
// No SystemPrompt, SkillsDir, or MCPConfig
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
// Verify shouldSkipClaudeCLI returns true
|
|
assert.True(t, exec.shouldSkipClaudeCLI(), "Should skip Claude CLI when no prompts/skills/mcp")
|
|
|
|
// Execute Stream - it should return immediately without calling Claude CLI
|
|
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
|
|
messages := []agentContext.Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
|
|
response, err := exec.Stream(ctx, messages, nil)
|
|
require.NoError(t, err, "Stream should succeed")
|
|
require.NotNil(t, response, "Response should not be nil")
|
|
|
|
// Verify response indicates skip
|
|
assert.Contains(t, response.ID, "sandbox-skip", "Response ID should indicate skip")
|
|
assert.Equal(t, "sandbox", response.Model, "Model should be 'sandbox' for skip mode")
|
|
assert.Empty(t, response.Content, "Content should be empty for skip mode")
|
|
|
|
t.Log("✓ Claude CLI skip mode verified")
|
|
}
|
|
|
|
// TestE2EExecuteClaudeCLI verifies that Claude CLI is called when prompts are configured
|
|
// This requires the real yaoapp/sandbox-claude image and a valid connector
|
|
func TestE2EExecuteClaudeCLI(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test in short mode")
|
|
}
|
|
|
|
// Check for required environment variables
|
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
|
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
|
|
model := os.Getenv("DEEPSEEK_MODELS_V3")
|
|
|
|
if apiKey == "" || apiProxy == "" || model == "" {
|
|
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
|
|
}
|
|
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
// Get data root from environment
|
|
dataRoot := os.Getenv("YAO_ROOT")
|
|
if dataRoot == "" {
|
|
t.Skip("Skipping test: YAO_ROOT not set")
|
|
}
|
|
|
|
// Create config with proper paths
|
|
cfg := infraSandbox.DefaultConfig()
|
|
cfg.Init(dataRoot)
|
|
|
|
manager, err := infraSandbox.NewManager(cfg)
|
|
if err != nil {
|
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Create options WITH SystemPrompt (triggers Claude CLI execution)
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-e2e-exec-%d", time.Now().UnixNano()),
|
|
ConnectorHost: apiProxy,
|
|
ConnectorKey: apiKey,
|
|
Model: model,
|
|
SystemPrompt: "You are a helpful assistant. Keep responses brief.",
|
|
Timeout: 5 * time.Minute,
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
if err != nil {
|
|
t.Skipf("Skipping test: Failed to create executor: %v", err)
|
|
}
|
|
defer exec.Close()
|
|
|
|
// Verify shouldSkipClaudeCLI returns false
|
|
assert.False(t, exec.shouldSkipClaudeCLI(), "Should NOT skip Claude CLI when prompts are configured")
|
|
|
|
// Execute Stream with a simple prompt
|
|
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
|
|
messages := []agentContext.Message{
|
|
{Role: "user", Content: "Reply with exactly: TEST_SUCCESS"},
|
|
}
|
|
|
|
// Collect streaming output
|
|
var streamedContent strings.Builder
|
|
streamHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
|
if chunkType == message.ChunkText {
|
|
streamedContent.Write(data)
|
|
}
|
|
return 0 // continue streaming
|
|
}
|
|
|
|
t.Log("Executing Claude CLI with real API call...")
|
|
startTime := time.Now()
|
|
response, err := exec.Stream(ctx, messages, streamHandler)
|
|
duration := time.Since(startTime)
|
|
t.Logf("Execution took: %v", duration)
|
|
|
|
if err != nil {
|
|
t.Logf("Stream error (might be expected if Docker/API issue): %v", err)
|
|
t.Skipf("Skipping assertion: %v", err)
|
|
}
|
|
|
|
require.NotNil(t, response, "Response should not be nil")
|
|
|
|
// Log response details
|
|
t.Logf("Response ID: %s", response.ID)
|
|
t.Logf("Response Model: %s", response.Model)
|
|
t.Logf("Response Content: %v", response.Content)
|
|
t.Logf("Streamed Content: %s", streamedContent.String())
|
|
|
|
// Verify we got some response
|
|
var fullResponse string
|
|
if content, ok := response.Content.(string); ok {
|
|
fullResponse = content
|
|
}
|
|
if fullResponse == "" {
|
|
fullResponse = streamedContent.String()
|
|
}
|
|
|
|
if fullResponse != "" {
|
|
t.Logf("✓ Claude CLI executed successfully with response: %s", truncate(fullResponse, 200))
|
|
} else {
|
|
t.Log("⚠ Empty response (Claude CLI might have issues)")
|
|
}
|
|
}
|
|
|
|
// TestE2EBuildInputJSONLIntegration tests the full flow of building input JSONL
|
|
func TestE2EBuildInputJSONLIntegration(t *testing.T) {
|
|
// Test with conversation history
|
|
messages := []agentContext.Message{
|
|
{Role: "system", Content: "You are a helpful assistant"},
|
|
{Role: "user", Content: "What is 2+2?"},
|
|
{Role: "assistant", Content: "4"},
|
|
{Role: "user", Content: "What about 3+3?"},
|
|
}
|
|
|
|
jsonl, err := BuildInputJSONL(messages)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Input JSONL:\n%s", string(jsonl))
|
|
|
|
// Verify format
|
|
lines := strings.Split(string(jsonl), "\n")
|
|
assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 lines (user, assistant, user)")
|
|
|
|
// System message should NOT be in JSONL
|
|
assert.NotContains(t, string(jsonl), "You are a helpful assistant", "System message should not be in JSONL")
|
|
|
|
// User and assistant messages should be present
|
|
assert.Contains(t, string(jsonl), "What is 2+2", "First user message should be present")
|
|
assert.Contains(t, string(jsonl), "4", "Assistant response should be present")
|
|
assert.Contains(t, string(jsonl), "What about 3+3", "Second user message should be present")
|
|
|
|
t.Log("✓ Input JSONL format verified")
|
|
}
|
|
|
|
// TestE2EBuildCommand tests the full command building
|
|
func TestE2EBuildCommand(t *testing.T) {
|
|
messages := []agentContext.Message{
|
|
{Role: "system", Content: "You are helpful"},
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
|
|
opts := &Options{
|
|
ConnectorHost: "https://api.example.com",
|
|
ConnectorKey: "test-key",
|
|
Model: "test-model",
|
|
Arguments: map[string]interface{}{
|
|
"permission_mode": "bypassPermissions",
|
|
},
|
|
MCPConfig: []byte(`{"mcpServers":{}}`),
|
|
}
|
|
|
|
cmd, env, err := BuildCommand(messages, opts)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("Command: %v", cmd)
|
|
t.Logf("Environment: %v", env)
|
|
|
|
// Verify command structure
|
|
assert.Equal(t, "bash", cmd[0])
|
|
assert.Equal(t, "-c", cmd[1])
|
|
|
|
bashCmd := cmd[2]
|
|
// Should use heredoc with INPUTEOF
|
|
assert.Contains(t, bashCmd, "cat << 'INPUTEOF'", "Should use heredoc")
|
|
assert.Contains(t, bashCmd, "INPUTEOF", "Should have INPUTEOF delimiter")
|
|
|
|
// Should have streaming flags
|
|
assert.Contains(t, bashCmd, "--input-format", "Should have input-format flag")
|
|
assert.Contains(t, bashCmd, "--output-format", "Should have output-format flag")
|
|
assert.Contains(t, bashCmd, "--verbose", "Should have verbose flag")
|
|
assert.Contains(t, bashCmd, "stream-json", "Should use stream-json format")
|
|
|
|
// Should have permission flags
|
|
assert.Contains(t, bashCmd, "--dangerously-skip-permissions", "Should have skip-permissions flag")
|
|
assert.Contains(t, bashCmd, "--permission-mode", "Should have permission-mode flag")
|
|
assert.Contains(t, bashCmd, "bypassPermissions", "Should have bypassPermissions value")
|
|
|
|
// Should have MCP config
|
|
assert.Contains(t, bashCmd, "--mcp-config", "Should have mcp-config flag")
|
|
|
|
// Environment should have proxy settings
|
|
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
|
|
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
|
|
|
|
// System prompt should be passed via CLI argument, not environment variable
|
|
// CLAUDE_SYSTEM_PROMPT env var is NOT supported by Claude CLI
|
|
assert.Contains(t, bashCmd, "--append-system-prompt", "Should have append-system-prompt flag")
|
|
assert.Contains(t, bashCmd, "You are helpful", "System prompt should be in CLI args")
|
|
|
|
t.Log("✓ Command building verified")
|
|
}
|
|
|
|
// Helper function to truncate strings
|
|
func truncate(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|