diff --git a/agent/caller/sandbox_integration_test.go b/agent/caller/sandbox_integration_test.go new file mode 100644 index 00000000..6730e50d --- /dev/null +++ b/agent/caller/sandbox_integration_test.go @@ -0,0 +1,335 @@ +package caller_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/assistant" + "github.com/yaoapp/yao/agent/caller" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/testutils" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// TestSandboxE2E_ClaudeCLIExecution tests the full sandbox + claude-proxy integration +// This test verifies: +// 1. Assistant loads with sandbox and prompts configured +// 2. Claude CLI is invoked (not skipped) because prompts exist +// 3. claude-proxy correctly translates requests to OpenAI backend +// 4. Response is received with actual content +func TestSandboxE2E_ClaudeCLIExecution(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox E2E test in short mode") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Load the e2e-test assistant + ast, err := assistant.Get("tests.sandbox.e2e-test") + if err != nil { + t.Skipf("Skipping test: e2e-test assistant not available: %v", err) + } + + // Verify configuration + require.NotNil(t, ast.Sandbox, "Sandbox should be configured") + require.NotEmpty(t, ast.Prompts, "Prompts should be configured (required for Claude CLI)") + t.Logf("✓ Assistant loaded: sandbox=%s, prompts=%d", ast.Sandbox.Command, len(ast.Prompts)) + + // Create authorized info + authorized := &types.AuthorizedInfo{ + Subject: "sandbox-e2e-test", + UserID: "e2e-user-123", + TenantID: "e2e-tenant", + } + + // Create context with unique chat ID + chatID := "sandbox-e2e-" + time.Now().Format("20060102-150405") + ctx := agentContext.New(context.Background(), authorized, chatID) + ctx.AssistantID = "tests.sandbox.e2e-test" + + // Create JSAPI + api := caller.NewJSAPI(ctx) + + // Test 1: Simple echo command + t.Run("EchoCommand", func(t *testing.T) { + messages := []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "Run this command: echo 'SANDBOX_E2E_SUCCESS_12345'", + }, + } + + opts := map[string]interface{}{ + "skip": map[string]interface{}{ + "history": true, + }, + } + + startTime := time.Now() + result := api.Call("tests.sandbox.e2e-test", messages, opts) + duration := time.Since(startTime) + t.Logf("Execution time: %v", duration) + + require.NotNil(t, result, "Result should not be nil") + + r, ok := result.(*caller.Result) + require.True(t, ok, "Result should be *caller.Result") + + // Check for errors + if r.Error != "" { + // Check if it's a Docker/sandbox availability issue + if strings.Contains(r.Error, "Docker") || + strings.Contains(r.Error, "sandbox") || + strings.Contains(r.Error, "container") { + t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error) + } + t.Fatalf("Agent call failed: %s", r.Error) + } + + // Verify response + t.Logf("Response content: %s", truncateStr(r.Content, 500)) + assert.NotEmpty(t, r.Content, "Response content should not be empty") + + // Check if Claude executed the command + if strings.Contains(r.Content, "SANDBOX_E2E_SUCCESS_12345") { + t.Log("✓ Echo command executed successfully - found verification string") + } else if strings.Contains(strings.ToLower(r.Content), "echo") || + strings.Contains(r.Content, "SANDBOX") { + t.Log("✓ Response mentions the command or partial output") + } else { + t.Log("⚠ Response does not contain expected output") + } + }) +} + +// TestSandboxE2E_FileCreation tests that Claude can create files in the sandbox +func TestSandboxE2E_FileCreation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox E2E test in short mode") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Load the e2e-test assistant + ast, err := assistant.Get("tests.sandbox.e2e-test") + if err != nil { + t.Skipf("Skipping test: e2e-test assistant not available: %v", err) + } + + require.NotNil(t, ast.Sandbox) + require.NotEmpty(t, ast.Prompts) + + // Create context + authorized := &types.AuthorizedInfo{ + Subject: "sandbox-e2e-test", + UserID: "e2e-user-456", + TenantID: "e2e-tenant", + } + + chatID := "sandbox-file-" + time.Now().Format("20060102-150405") + ctx := agentContext.New(context.Background(), authorized, chatID) + ctx.AssistantID = "tests.sandbox.e2e-test" + + api := caller.NewJSAPI(ctx) + + messages := []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "Create a file named 'test-output.txt' with the content 'FILE_CREATION_VERIFIED_67890', then read it back and show me the content.", + }, + } + + opts := map[string]interface{}{ + "skip": map[string]interface{}{ + "history": true, + }, + } + + startTime := time.Now() + result := api.Call("tests.sandbox.e2e-test", messages, opts) + duration := time.Since(startTime) + t.Logf("Execution time: %v", duration) + + require.NotNil(t, result) + + r, ok := result.(*caller.Result) + require.True(t, ok) + + if r.Error != "" { + if strings.Contains(r.Error, "Docker") || + strings.Contains(r.Error, "sandbox") { + t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error) + } + t.Fatalf("Agent call failed: %s", r.Error) + } + + t.Logf("Response: %s", truncateStr(r.Content, 800)) + + // Verify file was created and read back + if strings.Contains(r.Content, "FILE_CREATION_VERIFIED_67890") { + t.Log("✓ File creation and read verified") + } else if strings.Contains(strings.ToLower(r.Content), "created") || + strings.Contains(strings.ToLower(r.Content), "wrote") || + strings.Contains(r.Content, "test-output.txt") { + t.Log("✓ File operation appears successful") + } else { + t.Log("⚠ Could not verify file creation") + } +} + +// TestSandboxE2E_HookOnlyMode tests that hooks can work without Claude CLI +func TestSandboxE2E_HookOnlyMode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox E2E test in short mode") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Load the hook-only assistant (no prompts) + ast, err := assistant.Get("tests.sandbox.hook-only") + if err != nil { + t.Skipf("Skipping test: hook-only assistant not available: %v", err) + } + + // Verify configuration - no prompts means Claude CLI should be skipped + require.NotNil(t, ast.Sandbox) + require.Empty(t, ast.Prompts, "Hook-only mode should have no prompts") + t.Logf("✓ Hook-only assistant loaded: sandbox=%s, prompts=%d (should be 0)", ast.Sandbox.Command, len(ast.Prompts)) + + // Create context + authorized := &types.AuthorizedInfo{ + Subject: "sandbox-hook-test", + UserID: "hook-user-789", + TenantID: "hook-tenant", + } + + chatID := "sandbox-hook-" + time.Now().Format("20060102-150405") + ctx := agentContext.New(context.Background(), authorized, chatID) + ctx.AssistantID = "tests.sandbox.hook-only" + + api := caller.NewJSAPI(ctx) + + messages := []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "test hook-only mode", + }, + } + + opts := map[string]interface{}{ + "skip": map[string]interface{}{ + "history": true, + }, + } + + startTime := time.Now() + result := api.Call("tests.sandbox.hook-only", messages, opts) + duration := time.Since(startTime) + t.Logf("Execution time: %v", duration) + + require.NotNil(t, result) + + r, ok := result.(*caller.Result) + require.True(t, ok) + + if r.Error != "" { + if strings.Contains(r.Error, "Docker") || + strings.Contains(r.Error, "sandbox") { + t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error) + } + t.Fatalf("Agent call failed: %s", r.Error) + } + + t.Logf("Response: %s", r.Content) + t.Log("✓ Hook-only mode executed successfully") +} + +// TestSandboxE2E_StreamingResponse verifies streaming works correctly +func TestSandboxE2E_StreamingResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping sandbox E2E test in short mode") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Load the e2e-test assistant + ast, err := assistant.Get("tests.sandbox.e2e-test") + if err != nil { + t.Skipf("Skipping test: e2e-test assistant not available: %v", err) + } + + require.NotNil(t, ast.Sandbox) + require.NotEmpty(t, ast.Prompts) + + // Create context + authorized := &types.AuthorizedInfo{ + Subject: "sandbox-stream-test", + UserID: "stream-user", + TenantID: "stream-tenant", + } + + chatID := "sandbox-stream-" + time.Now().Format("20060102-150405") + ctx := agentContext.New(context.Background(), authorized, chatID) + ctx.AssistantID = "tests.sandbox.e2e-test" + + api := caller.NewJSAPI(ctx) + + // Ask for a slightly longer response to verify streaming + messages := []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "Say 'Hello World' and nothing else.", + }, + } + + opts := map[string]interface{}{ + "skip": map[string]interface{}{ + "history": true, + }, + } + + startTime := time.Now() + result := api.Call("tests.sandbox.e2e-test", messages, opts) + duration := time.Since(startTime) + t.Logf("Execution time: %v", duration) + + require.NotNil(t, result) + + r, ok := result.(*caller.Result) + require.True(t, ok) + + if r.Error != "" { + if strings.Contains(r.Error, "Docker") || + strings.Contains(r.Error, "sandbox") { + t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error) + } + t.Fatalf("Agent call failed: %s", r.Error) + } + + t.Logf("Response: %s", r.Content) + + // Verify we got a response + assert.NotEmpty(t, r.Content, "Should have response content") + + if strings.Contains(strings.ToLower(r.Content), "hello") { + t.Log("✓ Streaming response received with expected content") + } else { + t.Log("✓ Streaming response received") + } +} + +func truncateStr(s string, maxLen int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 6bcb44ef..a0bd6d69 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -283,7 +283,11 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me return e.Stream(ctx, messages, nil) } -// parseStream parses Claude CLI streaming output +// parseStream parses Claude CLI streaming output (stream-json format) +// Claude CLI output format: +// - {"type":"system","subtype":"init",...} - initialization +// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - assistant messages +// - {"type":"result","subtype":"success",...,"result":"..."} - final result func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { scanner := bufio.NewScanner(reader) // Increase buffer size for potentially large outputs @@ -294,6 +298,7 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a var toolCalls []agentContext.ToolCall var model string var usage *message.UsageInfo + var finalResult string for scanner.Scan() { line := scanner.Text() @@ -301,11 +306,8 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a continue } - // Note: Docker stream demuxing is handled by sandbox.Manager.Stream() - // which uses stdcopy.StdCopy to properly separate stdout/stderr - // Try to parse as JSON (Claude CLI --output-format stream-json) - var msg StreamMessage + var msg map[string]interface{} if err := json.Unmarshal([]byte(line), &msg); err != nil { // Not JSON, might be plain text output textContent.WriteString(line) @@ -313,24 +315,65 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a continue } - // Process different message types - switch msg.Type { - case "content_block_delta": - // Streaming text content - if delta, ok := msg.Content.(map[string]interface{}); ok { - if text, ok := delta["text"].(string); ok { - textContent.WriteString(text) - // Send to stream handler if available - if handler != nil { - handler(message.ChunkText, []byte(text)) - } - } + msgType, _ := msg["type"].(string) + + // Process Claude CLI stream-json message types + switch msgType { + case "system": + // Initialization message - extract model if available + if m, ok := msg["model"].(string); ok { + model = m } - case "message_delta": - // Message completion with usage - if content, ok := msg.Content.(map[string]interface{}); ok { - if usageData, ok := content["usage"].(map[string]interface{}); ok { + case "assistant": + // Assistant message - extract content + if msgData, ok := msg["message"].(map[string]interface{}); ok { + // Get model from message + if m, ok := msgData["model"].(string); ok && model == "" { + model = m + } + + // Extract content array + if contentArr, ok := msgData["content"].([]interface{}); ok { + for _, item := range contentArr { + if contentItem, ok := item.(map[string]interface{}); ok { + itemType, _ := contentItem["type"].(string) + + switch itemType { + case "text": + if text, ok := contentItem["text"].(string); ok { + // Only write if this is new content (avoid duplicates) + if textContent.Len() == 0 || !strings.HasSuffix(textContent.String(), text) { + textContent.WriteString(text) + } + // Send to stream handler if available + if handler != nil { + handler(message.ChunkText, []byte(text)) + } + } + + case "tool_use": + toolCall := agentContext.ToolCall{ + ID: getString(contentItem, "id"), + Type: agentContext.ToolTypeFunction, + Function: agentContext.Function{ + Name: getString(contentItem, "name"), + }, + } + // Get input as JSON string + if input, ok := contentItem["input"]; ok { + if inputJSON, err := json.Marshal(input); err == nil { + toolCall.Function.Arguments = string(inputJSON) + } + } + toolCalls = append(toolCalls, toolCall) + } + } + } + } + + // Extract usage + if usageData, ok := msgData["usage"].(map[string]interface{}); ok { usage = &message.UsageInfo{} if v, ok := usageData["input_tokens"].(float64); ok { usage.PromptTokens = int(v) @@ -342,32 +385,26 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a } } - case "message_start": - // Extract model from message_start - if content, ok := msg.Content.(map[string]interface{}); ok { - if m, ok := content["model"].(string); ok { - model = m - } + case "result": + // Final result message + if result, ok := msg["result"].(string); ok { + finalResult = result } - - case "content_block_start": - // Might contain tool use blocks - if block, ok := msg.Content.(map[string]interface{}); ok { - if block["type"] == "tool_use" { - toolCall := agentContext.ToolCall{ - ID: getString(block, "id"), - Type: agentContext.ToolTypeFunction, - Function: agentContext.Function{ - Name: getString(block, "name"), - Arguments: "{}", - }, - } - toolCalls = append(toolCalls, toolCall) - } + // Send done signal to handler + if handler != nil { + handler(message.ChunkMessageEnd, nil) } case "error": - return nil, fmt.Errorf("Claude CLI error: %s", msg.Error) + // Error message + if errMsg, ok := msg["error"].(string); ok { + return nil, fmt.Errorf("Claude CLI error: %s", errMsg) + } + if errObj, ok := msg["error"].(map[string]interface{}); ok { + if errMsg, ok := errObj["message"].(string); ok { + return nil, fmt.Errorf("Claude CLI error: %s", errMsg) + } + } } } @@ -375,13 +412,19 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a return nil, fmt.Errorf("error reading stream: %w", err) } + // Use final result if available, otherwise use accumulated text content + content := textContent.String() + if finalResult != "" && content == "" { + content = finalResult + } + // Build response response := &agentContext.CompletionResponse{ ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Model: model, Created: time.Now().Unix(), Role: "assistant", - Content: textContent.String(), + Content: content, FinishReason: agentContext.FinishReasonStop, } diff --git a/agent/sandbox/claude/real_e2e_test.go b/agent/sandbox/claude/real_e2e_test.go new file mode 100644 index 00000000..50c8f2e6 --- /dev/null +++ b/agent/sandbox/claude/real_e2e_test.go @@ -0,0 +1,310 @@ +package claude + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "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" +) + +// TestRealClaudeCLIExecution tests real Claude CLI execution with streaming +// This test requires: +// 1. Docker running with yaoapp/sandbox-claude:latest image +// 2. Environment variables: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, DEEPSEEK_MODELS_V3 +func TestRealClaudeCLIExecution(t *testing.T) { + if testing.Short() { + t.Skip("Skipping real 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-real-e2e-%d", time.Now().UnixNano()), + ConnectorHost: apiProxy, + ConnectorKey: apiKey, + Model: model, + SystemPrompt: "You are a helpful assistant. Reply concisely.", + Timeout: 3 * time.Minute, + } + + t.Logf("Creating executor with options:") + t.Logf(" ConnectorHost: %s", opts.ConnectorHost) + t.Logf(" Model: %s", opts.Model) + t.Logf(" SystemPrompt: %s", opts.SystemPrompt) + + 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 + if exec.shouldSkipClaudeCLI() { + t.Fatal("shouldSkipClaudeCLI should return false when SystemPrompt is set") + } + + // Test 1: First, manually test claude-proxy + t.Log("=== Test 1: Verify claude-proxy is working ===") + stdCtx := context.Background() + + // Prepare environment (this starts claude-proxy) + err = exec.prepareEnvironment(stdCtx) + require.NoError(t, err, "prepareEnvironment should succeed") + + // Check proxy is running + result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil) + if err != nil || result.ExitCode != 0 { + t.Log("claude-proxy not running, checking why...") + // Check proxy log + logContent, _ := exec.ReadFile(stdCtx, "proxy.log") + t.Logf("Proxy log: %s", string(logContent)) + + // Check config + configContent, _ := exec.ReadFile(stdCtx, ".claude-proxy.json") + t.Logf("Proxy config: %s", string(configContent)) + } else { + t.Logf("claude-proxy is running with PID: %s", strings.TrimSpace(result.Stdout)) + } + + // Test 2: Test simple command execution + t.Log("=== Test 2: Simple command execution ===") + ctx := agentContext.New(stdCtx, nil, opts.ChatID) + messages := []agentContext.Message{ + {Role: "user", Content: "Reply with exactly: HELLO_TEST_SUCCESS"}, + } + + // Collect streaming output + var streamedChunks []string + var streamedContent strings.Builder + streamHandler := func(chunkType message.StreamChunkType, data []byte) int { + chunk := string(data) + streamedChunks = append(streamedChunks, chunk) + streamedContent.Write(data) + t.Logf("Stream chunk [%s]: %q", chunkType, chunk) + return 0 // continue streaming + } + + t.Log("Executing Claude CLI...") + 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: %v", err) + + // Debug: check what's in the container + t.Log("=== Debug info ===") + + // Check proxy log + logContent, _ := exec.ReadFile(stdCtx, "proxy.log") + t.Logf("Proxy log:\n%s", string(logContent)) + + // List workspace + output, _ := exec.Exec(stdCtx, []string{"ls", "-la", "/workspace"}) + t.Logf("Workspace contents:\n%s", output) + + // Check environment + output, _ = exec.Exec(stdCtx, []string{"env"}) + t.Logf("Environment:\n%s", output) + + t.Fatalf("Stream failed: %v", err) + } + + require.NotNil(t, response, "Response should not be nil") + + // Log results + t.Logf("=== Results ===") + t.Logf("Response ID: %s", response.ID) + t.Logf("Response Model: %s", response.Model) + t.Logf("Response Content: %v", response.Content) + t.Logf("Streamed chunks count: %d", len(streamedChunks)) + t.Logf("Total 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 == "" { + // Check proxy log for errors + logContent, _ := exec.ReadFile(stdCtx, "proxy.log") + t.Logf("Proxy log (for debugging):\n%s", string(logContent)) + t.Fatal("Got empty response from Claude CLI") + } + + t.Logf("✓ Successfully got response: %s", fullResponse) + + // Check if streaming worked + if len(streamedChunks) > 0 { + t.Logf("✓ Streaming worked with %d chunks", len(streamedChunks)) + } else { + t.Log("⚠ No streaming chunks received (might be buffered)") + } +} + +// TestClaudeCLIDirectExecution tests running claude directly in the container +func TestClaudeCLIDirectExecution(t *testing.T) { + if testing.Short() { + t.Skip("Skipping real 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() + + dataRoot := os.Getenv("YAO_ROOT") + if dataRoot == "" { + t.Skip("Skipping test: YAO_ROOT not set") + } + + 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() + + opts := &Options{ + Command: "claude", + Image: "yaoapp/sandbox-claude:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-direct-%d", time.Now().UnixNano()), + ConnectorHost: apiProxy, + ConnectorKey: apiKey, + Model: model, + Timeout: 3 * time.Minute, + } + + exec, err := NewExecutor(manager, opts) + if err != nil { + t.Skipf("Skipping test: Failed to create executor: %v", err) + } + defer exec.Close() + + stdCtx := context.Background() + + // Step 1: Write proxy config and start proxy + t.Log("=== Step 1: Start claude-proxy ===") + err = exec.prepareEnvironment(stdCtx) + require.NoError(t, err) + + // Wait for proxy to start + time.Sleep(2 * time.Second) + + // Check proxy status + result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil) + if err == nil && result.ExitCode == 0 { + t.Logf("✓ claude-proxy running, PID: %s", strings.TrimSpace(result.Stdout)) + } else { + t.Log("⚠ claude-proxy might not be running") + } + + // Step 2: Run claude CLI directly with simple prompt + t.Log("=== Step 2: Run claude CLI directly ===") + + // Build a simple command - pass env vars explicitly + directCmd := []string{ + "bash", "-c", + `echo '{"type":"user","message":{"role":"user","content":"say hello"}}' | claude -p --dangerously-skip-permissions --permission-mode bypassPermissions --input-format stream-json --output-format stream-json --verbose 2>&1`, + } + + reader, err := exec.manager.Stream(stdCtx, exec.containerName, directCmd, &infraSandbox.ExecOptions{ + WorkDir: exec.workDir, + Timeout: 2 * time.Minute, + Env: map[string]string{ + "ANTHROPIC_BASE_URL": "http://127.0.0.1:3456", + "ANTHROPIC_API_KEY": "dummy", + }, + }) + if err != nil { + t.Fatalf("Failed to execute: %v", err) + } + defer reader.Close() + + // Read output + buf := make([]byte, 64*1024) + var output strings.Builder + for { + n, err := reader.Read(buf) + if n > 0 { + chunk := string(buf[:n]) + output.WriteString(chunk) + t.Logf("Output chunk: %q", chunk) + } + if err != nil { + break + } + } + + t.Logf("=== Full output ===\n%s", output.String()) + + if output.Len() == 0 { + // Check logs + logContent, _ := exec.ReadFile(stdCtx, "proxy.log") + t.Logf("Proxy log:\n%s", string(logContent)) + t.Fatal("Got no output from claude CLI") + } + + // Check for success indicators + outputStr := output.String() + if strings.Contains(outputStr, "error") || strings.Contains(outputStr, "Error") { + t.Logf("⚠ Output contains error") + } + if strings.Contains(outputStr, "content_block") || strings.Contains(outputStr, "message_start") { + t.Log("✓ Got streaming JSON output from Claude CLI") + } +} diff --git a/agent/sandbox/executor.go b/agent/sandbox/executor.go index 550cf24c..94f8bfb6 100644 --- a/agent/sandbox/executor.go +++ b/agent/sandbox/executor.go @@ -35,8 +35,9 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) { UserID: opts.UserID, ChatID: opts.ChatID, MCPConfig: opts.MCPConfig, - MCPTools: opts.MCPTools, // MCP tools to expose via IPC + MCPTools: opts.MCPTools, SkillsDir: opts.SkillsDir, + SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution ConnectorHost: opts.ConnectorHost, ConnectorKey: opts.ConnectorKey, Model: opts.Model,