Enhance File Handling and Input Parsing in Test Framework
- Introduced support for file attachments in test inputs using the `file://` protocol, allowing images, audio, and documents to be loaded and converted to appropriate formats. - Updated `ParseInput` and related functions to handle file references, ensuring seamless integration of file content into messages. - Enhanced error handling and path resolution for file loading, considering both relative paths and the `YAO_ROOT` environment variable. - Expanded documentation to include examples of file attachments and their usage in test cases, improving clarity for users.
This commit is contained in:
parent
97d0ad1d24
commit
7f44e442da
12 changed files with 1635 additions and 48 deletions
|
|
@ -272,6 +272,111 @@ The `options.metadata` field is passed to agent hooks. For example, a Create Hoo
|
||||||
| `Message` | Single message | `{"role": "user", "content": "..."}` |
|
| `Message` | Single message | `{"role": "user", "content": "..."}` |
|
||||||
| `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` |
|
| `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` |
|
||||||
|
|
||||||
|
### File Attachments
|
||||||
|
|
||||||
|
Test inputs support file attachments (images, audio, documents) using the `file://` protocol. Files are loaded and converted to appropriate formats for the LLM.
|
||||||
|
|
||||||
|
**Supported file types:**
|
||||||
|
|
||||||
|
| Type | Extensions | Format |
|
||||||
|
| ------ | ---------------------------------------------------------------------- | ------------------------------ |
|
||||||
|
| Image | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp` | Base64 data URL in `image_url` |
|
||||||
|
| Audio | `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a` | Base64 in `input_audio` |
|
||||||
|
| Doc | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.txt`, `.csv`, `.json` | Base64 data URL in `file` |
|
||||||
|
| Source | `.yao`, `.ts`, `.js`, `.go`, `.py`, `.rs`, `.java`, `.sql`, `.yaml`... | Base64 data URL in `file` |
|
||||||
|
|
||||||
|
**File path resolution:**
|
||||||
|
|
||||||
|
- **Relative paths**: Resolved relative to the JSONL input file's directory (for file mode) or current working directory (for message mode)
|
||||||
|
- **Absolute paths**: Used as-is
|
||||||
|
|
||||||
|
**Example with image attachment:**
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{
|
||||||
|
"id": "T001",
|
||||||
|
"input": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Please analyze this invoice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": "file://fixtures/invoice.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"type": "contains",
|
||||||
|
"value": "amount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example with multiple attachments:**
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{
|
||||||
|
"id": "T002",
|
||||||
|
"input": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Process these receipts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": "file://fixtures/receipt1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": "file://fixtures/receipt2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"source": "file://fixtures/policy.pdf",
|
||||||
|
"name": "expense_policy.pdf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example with audio:**
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{
|
||||||
|
"id": "T003",
|
||||||
|
"input": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Transcribe this audio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "audio",
|
||||||
|
"source": "file://fixtures/recording.wav"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content part types:**
|
||||||
|
|
||||||
|
| Type | Fields | Description |
|
||||||
|
| ----------- | --------------------------------------- | -------------------------------- |
|
||||||
|
| `text` | `text` | Text content |
|
||||||
|
| `image` | `source` (file://) or `url` | Image attachment |
|
||||||
|
| `image_url` | `image_url: {url, detail?}` | Direct image URL (OpenAI format) |
|
||||||
|
| `audio` | `source` (file://) or `data`, `format` | Audio attachment |
|
||||||
|
| `file` | `source` (file://) or `url`, `filename` | Document attachment |
|
||||||
|
| `data` | `data: {sources: [...]}` | Data source references |
|
||||||
|
|
||||||
## Assertions
|
## Assertions
|
||||||
|
|
||||||
Use `assert` for flexible validation. If `assert` is defined, it takes precedence over `expected`.
|
Use `assert` for flexible validation. If `assert` is defined, it takes precedence over `expected`.
|
||||||
|
|
|
||||||
|
|
@ -255,27 +255,32 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
|
||||||
result.Actual = actual
|
result.Actual = actual
|
||||||
|
|
||||||
// Compare expected value with actual value
|
// Compare expected value with actual value
|
||||||
// If expected is an array, check if actual matches ANY element (IN semantics)
|
// First try direct comparison (handles array-to-array comparison)
|
||||||
|
if validateOutput(actual, assertion.Value) {
|
||||||
|
result.Passed = true
|
||||||
|
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// If expected is an array and direct comparison failed, check if actual matches ANY element (IN semantics)
|
||||||
|
// This is for cases like: expected: ["a", "b"], actual: "a" (actual is one of expected)
|
||||||
if expectedArr, ok := assertion.Value.([]interface{}); ok {
|
if expectedArr, ok := assertion.Value.([]interface{}); ok {
|
||||||
// Check if actual is one of the expected values
|
// Only apply IN semantics if actual is NOT an array (otherwise it was already compared above)
|
||||||
for _, expectedItem := range expectedArr {
|
if _, actualIsArr := actual.([]interface{}); !actualIsArr {
|
||||||
if validateOutput(actual, expectedItem) {
|
for _, expectedItem := range expectedArr {
|
||||||
result.Passed = true
|
if validateOutput(actual, expectedItem) {
|
||||||
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
result.Passed = true
|
||||||
return result
|
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.Passed = false
|
result.Passed = false
|
||||||
result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
|
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||||
} else {
|
} else {
|
||||||
// Direct comparison for non-array expected values
|
// Direct comparison already failed above
|
||||||
if validateOutput(actual, assertion.Value) {
|
result.Passed = false
|
||||||
result.Passed = true
|
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||||
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
|
||||||
} else {
|
|
||||||
result.Passed = false
|
|
||||||
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
305
agent/test/assert_test.go
Normal file
305
agent/test/assert_test.go
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsserter_JSONPath_ArrayEquality(t *testing.T) {
|
||||||
|
asserter := NewAsserter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tc *Case
|
||||||
|
output interface{}
|
||||||
|
expected bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "array equals array - same content",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "search_types",
|
||||||
|
"value": []interface{}{"db"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"search_types": []interface{}{"db"},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array equals array - different content",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "search_types",
|
||||||
|
"value": []interface{}{"db"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"search_types": []interface{}{"web"},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array equals array - multiple elements",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "search_types",
|
||||||
|
"value": []interface{}{"web", "db"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"search_types": []interface{}{"web", "db"},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array equals array - different order",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "search_types",
|
||||||
|
"value": []interface{}{"db", "web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"search_types": []interface{}{"web", "db"},
|
||||||
|
},
|
||||||
|
expected: false, // Order matters for array equality
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scalar in array - match",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "status",
|
||||||
|
"value": []interface{}{"active", "pending"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
expected: true, // "active" is one of ["active", "pending"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scalar in array - no match",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "status",
|
||||||
|
"value": []interface{}{"active", "pending"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"status": "inactive",
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple value comparison",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "need_search",
|
||||||
|
"value": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"need_search": true,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested path",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "result.count",
|
||||||
|
"value": float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"count": float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array index access",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "items[0].name",
|
||||||
|
"value": "first",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"items": []interface{}{
|
||||||
|
map[string]interface{}{"name": "first"},
|
||||||
|
map[string]interface{}{"name": "second"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
passed, errMsg := asserter.Validate(tt.tc, tt.output)
|
||||||
|
if passed != tt.expected {
|
||||||
|
t.Errorf("Expected passed=%v, got passed=%v, error: %s", tt.expected, passed, errMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsserter_Contains(t *testing.T) {
|
||||||
|
asserter := NewAsserter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tc *Case
|
||||||
|
output interface{}
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string contains substring",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "hello world",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string does not contain",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "goodbye",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "hello world",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JSON contains field",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
passed, _ := asserter.Validate(tt.tc, tt.output)
|
||||||
|
if passed != tt.expected {
|
||||||
|
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsserter_MultipleAssertions(t *testing.T) {
|
||||||
|
asserter := NewAsserter()
|
||||||
|
|
||||||
|
tc := &Case{
|
||||||
|
Assert: []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "need_search",
|
||||||
|
"value": true,
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "search_types",
|
||||||
|
"value": []interface{}{"web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := map[string]interface{}{
|
||||||
|
"need_search": true,
|
||||||
|
"search_types": []interface{}{"web"},
|
||||||
|
}
|
||||||
|
|
||||||
|
passed, errMsg := asserter.Validate(tc, output)
|
||||||
|
if !passed {
|
||||||
|
t.Errorf("Expected all assertions to pass, got error: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsserter_Negate(t *testing.T) {
|
||||||
|
asserter := NewAsserter()
|
||||||
|
|
||||||
|
tc := &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "contains",
|
||||||
|
"value": "error",
|
||||||
|
"negate": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := "success message"
|
||||||
|
|
||||||
|
passed, _ := asserter.Validate(tc, output)
|
||||||
|
if !passed {
|
||||||
|
t.Error("Expected negated assertion to pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsserter_Regex(t *testing.T) {
|
||||||
|
asserter := NewAsserter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tc *Case
|
||||||
|
output interface{}
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "regex matches",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "regex",
|
||||||
|
"value": `\d{3}-\d{4}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "Phone: 123-4567",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex does not match",
|
||||||
|
tc: &Case{
|
||||||
|
Assert: map[string]interface{}{
|
||||||
|
"type": "regex",
|
||||||
|
"value": `\d{3}-\d{4}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: "No phone number here",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
passed, _ := asserter.Validate(tt.tc, tt.output)
|
||||||
|
if passed != tt.expected {
|
||||||
|
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,123 @@
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FileProtocol is the protocol prefix for local file references
|
||||||
|
const FileProtocol = "file://"
|
||||||
|
|
||||||
|
// SupportedImageExtensions lists supported image file extensions
|
||||||
|
var SupportedImageExtensions = map[string]string{
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedAudioExtensions lists supported audio file extensions
|
||||||
|
var SupportedAudioExtensions = map[string]string{
|
||||||
|
".wav": "wav",
|
||||||
|
".mp3": "mp3",
|
||||||
|
".flac": "flac",
|
||||||
|
".ogg": "ogg",
|
||||||
|
".m4a": "m4a",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedFileExtensions lists supported document file extensions
|
||||||
|
var SupportedFileExtensions = map[string]string{
|
||||||
|
// Documents
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".json": "application/json",
|
||||||
|
".xml": "application/xml",
|
||||||
|
".html": "text/html",
|
||||||
|
".htm": "text/html",
|
||||||
|
".md": "text/markdown",
|
||||||
|
|
||||||
|
// Source code
|
||||||
|
".yao": "application/json", // Yao DSL files (JSON-based)
|
||||||
|
".ts": "text/typescript", // TypeScript
|
||||||
|
".tsx": "text/typescript", // TypeScript JSX
|
||||||
|
".js": "text/javascript", // JavaScript
|
||||||
|
".jsx": "text/javascript", // JavaScript JSX
|
||||||
|
".go": "text/x-go", // Go
|
||||||
|
".py": "text/x-python", // Python
|
||||||
|
".rs": "text/x-rust", // Rust
|
||||||
|
".java": "text/x-java", // Java
|
||||||
|
".c": "text/x-c", // C
|
||||||
|
".cpp": "text/x-c++", // C++
|
||||||
|
".h": "text/x-c", // C header
|
||||||
|
".hpp": "text/x-c++", // C++ header
|
||||||
|
".rb": "text/x-ruby", // Ruby
|
||||||
|
".php": "text/x-php", // PHP
|
||||||
|
".sh": "text/x-shellscript", // Shell script
|
||||||
|
".bash": "text/x-shellscript", // Bash script
|
||||||
|
".zsh": "text/x-shellscript", // Zsh script
|
||||||
|
".sql": "text/x-sql", // SQL
|
||||||
|
".yaml": "text/yaml", // YAML
|
||||||
|
".yml": "text/yaml", // YAML
|
||||||
|
".toml": "text/x-toml", // TOML
|
||||||
|
".ini": "text/x-ini", // INI
|
||||||
|
".conf": "text/plain", // Config files
|
||||||
|
".css": "text/css", // CSS
|
||||||
|
".scss": "text/x-scss", // SCSS
|
||||||
|
".less": "text/x-less", // LESS
|
||||||
|
".vue": "text/x-vue", // Vue
|
||||||
|
".svelte": "text/x-svelte", // Svelte
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputOptions configures how input is parsed
|
||||||
|
type InputOptions struct {
|
||||||
|
// BaseDir is the base directory for resolving relative file paths
|
||||||
|
// If empty, the current working directory is used
|
||||||
|
BaseDir string
|
||||||
|
}
|
||||||
|
|
||||||
// ParseInput converts various input formats to []context.Message
|
// ParseInput converts various input formats to []context.Message
|
||||||
// Supported formats:
|
// Supported formats:
|
||||||
// - string: converted to single user message
|
// - string: converted to single user message
|
||||||
// - map (Message): single message with role and content
|
// - map (Message): single message with role and content
|
||||||
// - []interface{} ([]Message): array of messages (conversation history)
|
// - []interface{} ([]Message): array of messages (conversation history)
|
||||||
func ParseInput(input interface{}) ([]context.Message, error) {
|
func ParseInput(input interface{}) ([]context.Message, error) {
|
||||||
|
return ParseInputWithOptions(input, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseInputWithOptions converts various input formats to []context.Message with options
|
||||||
|
// Supported formats:
|
||||||
|
// - string: converted to single user message
|
||||||
|
// - map (Message): single message with role and content
|
||||||
|
// - []interface{} ([]Message): array of messages (conversation history)
|
||||||
|
//
|
||||||
|
// File references in content parts (type="image", "file", "audio") with "source" field
|
||||||
|
// starting with "file://" will be loaded and converted to appropriate format:
|
||||||
|
// - Images: converted to base64 data URL in image_url field
|
||||||
|
// - Audio: converted to base64 in input_audio field
|
||||||
|
// - Files: converted to base64 data URL in file field
|
||||||
|
func ParseInputWithOptions(input interface{}, opts *InputOptions) ([]context.Message, error) {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil, fmt.Errorf("input is nil")
|
return nil, fmt.Errorf("input is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
opts = &InputOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
switch v := input.(type) {
|
switch v := input.(type) {
|
||||||
case string:
|
case string:
|
||||||
// Simple string input -> single user message
|
// Simple string input -> single user message
|
||||||
|
|
@ -29,7 +130,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
||||||
|
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
// Single message object
|
// Single message object
|
||||||
msg, err := parseMessageMap(v)
|
msg, err := parseMessageMap(v, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse message: %w", err)
|
return nil, fmt.Errorf("failed to parse message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +142,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
||||||
for i, item := range v {
|
for i, item := range v {
|
||||||
switch m := item.(type) {
|
switch m := item.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
msg, err := parseMessageMap(m)
|
msg, err := parseMessageMap(m, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err)
|
return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +159,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMessageMap converts a map to context.Message
|
// parseMessageMap converts a map to context.Message
|
||||||
func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
func parseMessageMap(m map[string]interface{}, opts *InputOptions) (*context.Message, error) {
|
||||||
msg := &context.Message{}
|
msg := &context.Message{}
|
||||||
|
|
||||||
// Parse role (required)
|
// Parse role (required)
|
||||||
|
|
@ -71,7 +172,12 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
||||||
|
|
||||||
// Parse content (required)
|
// Parse content (required)
|
||||||
if content, ok := m["content"]; ok {
|
if content, ok := m["content"]; ok {
|
||||||
msg.Content = content
|
// Process content to handle file:// references
|
||||||
|
processedContent, err := processContent(content, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to process content: %w", err)
|
||||||
|
}
|
||||||
|
msg.Content = processedContent
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("message missing 'content' field")
|
return nil, fmt.Errorf("message missing 'content' field")
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +214,389 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processContent processes content to handle file:// references
|
||||||
|
// Returns the processed content with files loaded and converted
|
||||||
|
func processContent(content interface{}, opts *InputOptions) (interface{}, error) {
|
||||||
|
switch v := content.(type) {
|
||||||
|
case string:
|
||||||
|
// Simple string content, no processing needed
|
||||||
|
return v, nil
|
||||||
|
|
||||||
|
case []interface{}:
|
||||||
|
// Array of content parts
|
||||||
|
processedParts := make([]context.ContentPart, 0, len(v))
|
||||||
|
for i, part := range v {
|
||||||
|
if partMap, ok := part.(map[string]interface{}); ok {
|
||||||
|
processedPart, err := processContentPart(partMap, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to process content part at index %d: %w", i, err)
|
||||||
|
}
|
||||||
|
processedParts = append(processedParts, *processedPart)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("invalid content part type at index %d: expected object, got %T", i, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processedParts, nil
|
||||||
|
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Single content part
|
||||||
|
processedPart, err := processContentPart(v, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to process content part: %w", err)
|
||||||
|
}
|
||||||
|
return []context.ContentPart{*processedPart}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processContentPart processes a single content part map
|
||||||
|
// Handles file:// references and converts them to appropriate format
|
||||||
|
func processContentPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
partType, _ := partMap["type"].(string)
|
||||||
|
|
||||||
|
switch partType {
|
||||||
|
case "text":
|
||||||
|
text, _ := partMap["text"].(string)
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentText,
|
||||||
|
Text: text,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return processImagePart(partMap, opts)
|
||||||
|
|
||||||
|
case "image_url":
|
||||||
|
// Already in correct format, just parse it
|
||||||
|
return parseImageURLPart(partMap)
|
||||||
|
|
||||||
|
case "audio", "input_audio":
|
||||||
|
return processAudioPart(partMap, opts)
|
||||||
|
|
||||||
|
case "file":
|
||||||
|
return processFilePart(partMap, opts)
|
||||||
|
|
||||||
|
case "data":
|
||||||
|
return parseDataPart(partMap)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown type, try to preserve as-is
|
||||||
|
return parseGenericPart(partMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processImagePart processes an image content part
|
||||||
|
// Supports: source="file://path" for local files
|
||||||
|
func processImagePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
source, hasSource := partMap["source"].(string)
|
||||||
|
|
||||||
|
// Check for file:// protocol
|
||||||
|
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||||
|
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||||
|
return loadImageFile(filePath, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for url field (already a URL or base64)
|
||||||
|
if url, ok := partMap["url"].(string); ok {
|
||||||
|
detail := context.DetailAuto
|
||||||
|
if d, ok := partMap["detail"].(string); ok {
|
||||||
|
detail = context.ImageDetailLevel(d)
|
||||||
|
}
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentImageURL,
|
||||||
|
ImageURL: &context.ImageURL{
|
||||||
|
URL: url,
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("image part requires 'source' (file://...) or 'url' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageURLPart parses an image_url content part
|
||||||
|
func parseImageURLPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||||
|
imageURL, ok := partMap["image_url"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("image_url part requires 'image_url' object")
|
||||||
|
}
|
||||||
|
|
||||||
|
url, _ := imageURL["url"].(string)
|
||||||
|
detail := context.DetailAuto
|
||||||
|
if d, ok := imageURL["detail"].(string); ok {
|
||||||
|
detail = context.ImageDetailLevel(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentImageURL,
|
||||||
|
ImageURL: &context.ImageURL{
|
||||||
|
URL: url,
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processAudioPart processes an audio content part
|
||||||
|
// Supports: source="file://path" for local files
|
||||||
|
func processAudioPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
source, hasSource := partMap["source"].(string)
|
||||||
|
|
||||||
|
// Check for file:// protocol
|
||||||
|
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||||
|
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||||
|
return loadAudioFile(filePath, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data field (already base64)
|
||||||
|
if data, ok := partMap["data"].(string); ok {
|
||||||
|
format, _ := partMap["format"].(string)
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentInputAudio,
|
||||||
|
InputAudio: &context.InputAudio{
|
||||||
|
Data: data,
|
||||||
|
Format: format,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for input_audio field
|
||||||
|
if inputAudio, ok := partMap["input_audio"].(map[string]interface{}); ok {
|
||||||
|
data, _ := inputAudio["data"].(string)
|
||||||
|
format, _ := inputAudio["format"].(string)
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentInputAudio,
|
||||||
|
InputAudio: &context.InputAudio{
|
||||||
|
Data: data,
|
||||||
|
Format: format,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("audio part requires 'source' (file://...) or 'data'/'input_audio' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFilePart processes a file content part
|
||||||
|
// Supports: source="file://path" for local files
|
||||||
|
func processFilePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
source, hasSource := partMap["source"].(string)
|
||||||
|
|
||||||
|
// Check for file:// protocol
|
||||||
|
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||||
|
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||||
|
name, _ := partMap["name"].(string)
|
||||||
|
return loadFile(filePath, name, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for url field (already a URL)
|
||||||
|
if url, ok := partMap["url"].(string); ok {
|
||||||
|
filename, _ := partMap["filename"].(string)
|
||||||
|
if filename == "" {
|
||||||
|
filename, _ = partMap["name"].(string)
|
||||||
|
}
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentFile,
|
||||||
|
File: &context.FileAttachment{
|
||||||
|
URL: url,
|
||||||
|
Filename: filename,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for file field
|
||||||
|
if file, ok := partMap["file"].(map[string]interface{}); ok {
|
||||||
|
url, _ := file["url"].(string)
|
||||||
|
filename, _ := file["filename"].(string)
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentFile,
|
||||||
|
File: &context.FileAttachment{
|
||||||
|
URL: url,
|
||||||
|
Filename: filename,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("file part requires 'source' (file://...), 'url', or 'file' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDataPart parses a data content part
|
||||||
|
func parseDataPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||||
|
data, ok := partMap["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("data part requires 'data' object")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to DataContent
|
||||||
|
dataContent := &context.DataContent{}
|
||||||
|
|
||||||
|
if sources, ok := data["sources"].([]interface{}); ok {
|
||||||
|
dataContent.Sources = make([]context.DataSource, 0, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
if srcMap, ok := src.(map[string]interface{}); ok {
|
||||||
|
ds := context.DataSource{}
|
||||||
|
if t, ok := srcMap["type"].(string); ok {
|
||||||
|
ds.Type = context.DataSourceType(t)
|
||||||
|
}
|
||||||
|
if id, ok := srcMap["id"].(string); ok {
|
||||||
|
ds.ID = id
|
||||||
|
}
|
||||||
|
if name, ok := srcMap["name"].(string); ok {
|
||||||
|
ds.Name = name
|
||||||
|
}
|
||||||
|
if filters, ok := srcMap["filters"].(map[string]interface{}); ok {
|
||||||
|
ds.Filters = filters
|
||||||
|
}
|
||||||
|
if metadata, ok := srcMap["metadata"].(map[string]interface{}); ok {
|
||||||
|
ds.Metadata = metadata
|
||||||
|
}
|
||||||
|
dataContent.Sources = append(dataContent.Sources, ds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentData,
|
||||||
|
Data: dataContent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGenericPart tries to parse an unknown content part type
|
||||||
|
func parseGenericPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||||
|
partType, _ := partMap["type"].(string)
|
||||||
|
|
||||||
|
// Try to create a basic ContentPart
|
||||||
|
part := &context.ContentPart{
|
||||||
|
Type: context.ContentPartType(partType),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract text if present
|
||||||
|
if text, ok := partMap["text"].(string); ok {
|
||||||
|
part.Text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
return part, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadImageFile loads an image file and converts it to a ContentPart
|
||||||
|
func loadImageFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
absPath := resolveFilePath(filePath, opts)
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read image file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
ext := strings.ToLower(filepath.Ext(absPath))
|
||||||
|
mimeType, ok := SupportedImageExtensions[ext]
|
||||||
|
if !ok {
|
||||||
|
// Try to detect from extension
|
||||||
|
mimeType = mime.TypeByExtension(ext)
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64 data URL
|
||||||
|
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
|
||||||
|
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentImageURL,
|
||||||
|
ImageURL: &context.ImageURL{
|
||||||
|
URL: dataURL,
|
||||||
|
Detail: context.DetailAuto,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAudioFile loads an audio file and converts it to a ContentPart
|
||||||
|
func loadAudioFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
absPath := resolveFilePath(filePath, opts)
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read audio file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine format from extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(absPath))
|
||||||
|
format, ok := SupportedAudioExtensions[ext]
|
||||||
|
if !ok {
|
||||||
|
format = strings.TrimPrefix(ext, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||||
|
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentInputAudio,
|
||||||
|
InputAudio: &context.InputAudio{
|
||||||
|
Data: b64Data,
|
||||||
|
Format: format,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFile loads a file and converts it to a ContentPart
|
||||||
|
func loadFile(filePath string, name string, opts *InputOptions) (*context.ContentPart, error) {
|
||||||
|
absPath := resolveFilePath(filePath, opts)
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine filename
|
||||||
|
filename := name
|
||||||
|
if filename == "" {
|
||||||
|
filename = filepath.Base(absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
ext := strings.ToLower(filepath.Ext(absPath))
|
||||||
|
mimeType, ok := SupportedFileExtensions[ext]
|
||||||
|
if !ok {
|
||||||
|
mimeType = mime.TypeByExtension(ext)
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to base64 data URL
|
||||||
|
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||||
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
|
||||||
|
|
||||||
|
return &context.ContentPart{
|
||||||
|
Type: context.ContentFile,
|
||||||
|
File: &context.FileAttachment{
|
||||||
|
URL: dataURL,
|
||||||
|
Filename: filename,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFilePath resolves a file path relative to the base directory
|
||||||
|
// If the path is absolute, it's returned as-is
|
||||||
|
// If BaseDir is empty, the current working directory is used
|
||||||
|
func resolveFilePath(filePath string, opts *InputOptions) string {
|
||||||
|
// If path is absolute, return as-is
|
||||||
|
if filepath.IsAbs(filePath) {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// If BaseDir is set, resolve relative to it
|
||||||
|
if opts != nil && opts.BaseDir != "" {
|
||||||
|
return filepath.Join(opts.BaseDir, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, resolve relative to current working directory
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
// parseToolCall converts a map to context.ToolCall
|
// parseToolCall converts a map to context.ToolCall
|
||||||
func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) {
|
func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) {
|
||||||
tc := &context.ToolCall{}
|
tc := &context.ToolCall{}
|
||||||
|
|
|
||||||
487
agent/test/input_test.go
Normal file
487
agent/test/input_test.go
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseInput_String(t *testing.T) {
|
||||||
|
input := "Hello world"
|
||||||
|
messages, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInput failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[0].Role != context.RoleUser {
|
||||||
|
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := messages[0].Content.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected string content, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
if content != "Hello world" {
|
||||||
|
t.Errorf("Expected content 'Hello world', got '%s'", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInput_MessageMap(t *testing.T) {
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInput failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[0].Role != context.RoleUser {
|
||||||
|
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInput_MessageArray(t *testing.T) {
|
||||||
|
input := []interface{}{
|
||||||
|
map[string]interface{}{"role": "user", "content": "Hello"},
|
||||||
|
map[string]interface{}{"role": "assistant", "content": "Hi there"},
|
||||||
|
map[string]interface{}{"role": "user", "content": "Follow-up"},
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInput failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) != 3 {
|
||||||
|
t.Fatalf("Expected 3 messages, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[0].Role != context.RoleUser {
|
||||||
|
t.Errorf("Expected first message role 'user', got '%s'", messages[0].Role)
|
||||||
|
}
|
||||||
|
if messages[1].Role != context.RoleAssistant {
|
||||||
|
t.Errorf("Expected second message role 'assistant', got '%s'", messages[1].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInput_ContentParts(t *testing.T) {
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "Analyze this"},
|
||||||
|
map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"detail": "high",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInput failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("Expected 2 content parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[0].Type != context.ContentText {
|
||||||
|
t.Errorf("Expected first part type 'text', got '%s'", parts[0].Type)
|
||||||
|
}
|
||||||
|
if parts[0].Text != "Analyze this" {
|
||||||
|
t.Errorf("Expected text 'Analyze this', got '%s'", parts[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1].Type != context.ContentImageURL {
|
||||||
|
t.Errorf("Expected second part type 'image_url', got '%s'", parts[1].Type)
|
||||||
|
}
|
||||||
|
if parts[1].ImageURL == nil {
|
||||||
|
t.Fatal("Expected ImageURL to be set")
|
||||||
|
}
|
||||||
|
if parts[1].ImageURL.URL != "https://example.com/image.jpg" {
|
||||||
|
t.Errorf("Expected URL 'https://example.com/image.jpg', got '%s'", parts[1].ImageURL.URL)
|
||||||
|
}
|
||||||
|
if parts[1].ImageURL.Detail != context.DetailHigh {
|
||||||
|
t.Errorf("Expected detail 'high', got '%s'", parts[1].ImageURL.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputWithOptions_FileProtocol_Image(t *testing.T) {
|
||||||
|
// Create a temporary test image file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
imgPath := filepath.Join(tmpDir, "test.png")
|
||||||
|
|
||||||
|
// Create a minimal PNG file (1x1 pixel, red)
|
||||||
|
pngData := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||||
|
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
|
||||||
|
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||||
|
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
|
||||||
|
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk
|
||||||
|
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "Analyze this image"},
|
||||||
|
map[string]interface{}{"type": "image", "source": "file://test.png"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &InputOptions{BaseDir: tmpDir}
|
||||||
|
messages, err := ParseInputWithOptions(input, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("Expected 2 content parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check image part
|
||||||
|
imgPart := parts[1]
|
||||||
|
if imgPart.Type != context.ContentImageURL {
|
||||||
|
t.Errorf("Expected type 'image_url', got '%s'", imgPart.Type)
|
||||||
|
}
|
||||||
|
if imgPart.ImageURL == nil {
|
||||||
|
t.Fatal("Expected ImageURL to be set")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(imgPart.ImageURL.URL, "data:image/png;base64,") {
|
||||||
|
t.Errorf("Expected base64 data URL, got '%s'", imgPart.ImageURL.URL[:50])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the base64 content
|
||||||
|
b64Part := strings.TrimPrefix(imgPart.ImageURL.URL, "data:image/png;base64,")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(b64Part)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode base64: %v", err)
|
||||||
|
}
|
||||||
|
if len(decoded) != len(pngData) {
|
||||||
|
t.Errorf("Decoded data length mismatch: expected %d, got %d", len(pngData), len(decoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputWithOptions_FileProtocol_Audio(t *testing.T) {
|
||||||
|
// Create a temporary test audio file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(tmpDir, "test.wav")
|
||||||
|
|
||||||
|
// Create a minimal WAV file header
|
||||||
|
wavData := []byte{
|
||||||
|
0x52, 0x49, 0x46, 0x46, // "RIFF"
|
||||||
|
0x24, 0x00, 0x00, 0x00, // File size - 8
|
||||||
|
0x57, 0x41, 0x56, 0x45, // "WAVE"
|
||||||
|
0x66, 0x6D, 0x74, 0x20, // "fmt "
|
||||||
|
0x10, 0x00, 0x00, 0x00, // Subchunk1Size (16 for PCM)
|
||||||
|
0x01, 0x00, // AudioFormat (1 = PCM)
|
||||||
|
0x01, 0x00, // NumChannels (1 = mono)
|
||||||
|
0x44, 0xAC, 0x00, 0x00, // SampleRate (44100)
|
||||||
|
0x88, 0x58, 0x01, 0x00, // ByteRate
|
||||||
|
0x02, 0x00, // BlockAlign
|
||||||
|
0x10, 0x00, // BitsPerSample (16)
|
||||||
|
0x64, 0x61, 0x74, 0x61, // "data"
|
||||||
|
0x00, 0x00, 0x00, 0x00, // Subchunk2Size (0 = no data)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(audioPath, wavData, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test audio: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "Transcribe this"},
|
||||||
|
map[string]interface{}{"type": "audio", "source": "file://test.wav"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &InputOptions{BaseDir: tmpDir}
|
||||||
|
messages, err := ParseInputWithOptions(input, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check audio part
|
||||||
|
audioPart := parts[1]
|
||||||
|
if audioPart.Type != context.ContentInputAudio {
|
||||||
|
t.Errorf("Expected type 'input_audio', got '%s'", audioPart.Type)
|
||||||
|
}
|
||||||
|
if audioPart.InputAudio == nil {
|
||||||
|
t.Fatal("Expected InputAudio to be set")
|
||||||
|
}
|
||||||
|
if audioPart.InputAudio.Format != "wav" {
|
||||||
|
t.Errorf("Expected format 'wav', got '%s'", audioPart.InputAudio.Format)
|
||||||
|
}
|
||||||
|
if audioPart.InputAudio.Data == "" {
|
||||||
|
t.Error("Expected base64 data to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputWithOptions_FileProtocol_File(t *testing.T) {
|
||||||
|
// Create a temporary test file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
pdfPath := filepath.Join(tmpDir, "document.pdf")
|
||||||
|
|
||||||
|
// Create a minimal PDF file
|
||||||
|
pdfData := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF")
|
||||||
|
if err := os.WriteFile(pdfPath, pdfData, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test PDF: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "Analyze this document"},
|
||||||
|
map[string]interface{}{"type": "file", "source": "file://document.pdf", "name": "my_doc.pdf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &InputOptions{BaseDir: tmpDir}
|
||||||
|
messages, err := ParseInputWithOptions(input, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file part
|
||||||
|
filePart := parts[1]
|
||||||
|
if filePart.Type != context.ContentFile {
|
||||||
|
t.Errorf("Expected type 'file', got '%s'", filePart.Type)
|
||||||
|
}
|
||||||
|
if filePart.File == nil {
|
||||||
|
t.Fatal("Expected File to be set")
|
||||||
|
}
|
||||||
|
if filePart.File.Filename != "my_doc.pdf" {
|
||||||
|
t.Errorf("Expected filename 'my_doc.pdf', got '%s'", filePart.File.Filename)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(filePart.File.URL, "data:application/pdf;base64,") {
|
||||||
|
t.Errorf("Expected base64 data URL with PDF mime type, got '%s'", filePart.File.URL[:40])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputWithOptions_FileProtocol_AbsolutePath(t *testing.T) {
|
||||||
|
// Create a temporary test image file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
imgPath := filepath.Join(tmpDir, "absolute.png")
|
||||||
|
|
||||||
|
// Create a minimal PNG file
|
||||||
|
pngData := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||||
|
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41,
|
||||||
|
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||||
|
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
|
||||||
|
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||||
|
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use absolute path
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "image", "source": "file://" + imgPath},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseDir should be ignored for absolute paths
|
||||||
|
opts := &InputOptions{BaseDir: "/some/other/dir"}
|
||||||
|
messages, err := ParseInputWithOptions(input, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[0].Type != context.ContentImageURL {
|
||||||
|
t.Errorf("Expected type 'image_url', got '%s'", parts[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInputWithOptions_FileNotFound(t *testing.T) {
|
||||||
|
input := map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": []interface{}{
|
||||||
|
map[string]interface{}{"type": "image", "source": "file://nonexistent.png"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &InputOptions{BaseDir: t.TempDir()}
|
||||||
|
_, err := ParseInputWithOptions(input, opts)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for non-existent file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to read image file") {
|
||||||
|
t.Errorf("Expected 'failed to read image file' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFilePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filePath string
|
||||||
|
baseDir string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative path with base dir",
|
||||||
|
filePath: "fixtures/image.png",
|
||||||
|
baseDir: "/app/tests",
|
||||||
|
expected: "/app/tests/fixtures/image.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path without base dir",
|
||||||
|
filePath: "fixtures/image.png",
|
||||||
|
baseDir: "",
|
||||||
|
expected: "fixtures/image.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path ignores base dir",
|
||||||
|
filePath: "/absolute/path/image.png",
|
||||||
|
baseDir: "/app/tests",
|
||||||
|
expected: "/absolute/path/image.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
opts := &InputOptions{BaseDir: tt.baseDir}
|
||||||
|
result := resolveFilePath(tt.filePath, opts)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTextContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content interface{}
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string content",
|
||||||
|
content: "Hello world",
|
||||||
|
expected: "Hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content parts array",
|
||||||
|
content: []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "First"},
|
||||||
|
map[string]interface{}{"type": "image", "source": "file://test.png"},
|
||||||
|
map[string]interface{}{"type": "text", "text": "Second"},
|
||||||
|
},
|
||||||
|
expected: "First\nSecond",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil content",
|
||||||
|
content: nil,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ExtractTextContent(tt.content)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input interface{}
|
||||||
|
maxLen int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "short string",
|
||||||
|
input: "Hello",
|
||||||
|
maxLen: 10,
|
||||||
|
expected: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long string truncated",
|
||||||
|
input: "This is a very long message that should be truncated",
|
||||||
|
maxLen: 20,
|
||||||
|
expected: "This is a very lo...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message array - last user message",
|
||||||
|
input: []interface{}{
|
||||||
|
map[string]interface{}{"role": "user", "content": "First"},
|
||||||
|
map[string]interface{}{"role": "assistant", "content": "Response"},
|
||||||
|
map[string]interface{}{"role": "user", "content": "Last user message"},
|
||||||
|
},
|
||||||
|
maxLen: 50,
|
||||||
|
expected: "Last user message",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := SummarizeInput(tt.input, tt.maxLen)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,12 @@ func (l *JSONLLoader) Load() ([]*Case, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadFile loads test cases from a JSONL file
|
// LoadFile loads test cases from a JSONL file
|
||||||
|
// If path is relative and YAO_ROOT is set, resolves relative to YAO_ROOT
|
||||||
func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) {
|
func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) {
|
||||||
file, err := os.Open(path)
|
// Resolve path relative to YAO_ROOT if it's a relative path
|
||||||
|
resolvedPath := ResolvePathWithYaoRoot(path)
|
||||||
|
|
||||||
|
file, err := os.Open(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) {
|
||||||
|
|
||||||
// ResolveFromPath resolves the agent by traversing up from the input file path
|
// ResolveFromPath resolves the agent by traversing up from the input file path
|
||||||
// It looks for package.yao in parent directories
|
// It looks for package.yao in parent directories
|
||||||
|
// If YAO_ROOT is set, it also considers paths relative to YAO_ROOT
|
||||||
func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
|
func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
|
||||||
// Get absolute path
|
// Get absolute path
|
||||||
absPath, err := filepath.Abs(inputPath)
|
absPath, err := filepath.Abs(inputPath)
|
||||||
|
|
@ -82,6 +83,37 @@ func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
|
||||||
dir = parent
|
dir = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If YAO_ROOT is set, try resolving relative to it
|
||||||
|
yaoRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if yaoRoot != "" {
|
||||||
|
// Try the input path relative to YAO_ROOT
|
||||||
|
relPath := inputPath
|
||||||
|
// If inputPath is absolute, try to make it relative
|
||||||
|
if filepath.IsAbs(inputPath) {
|
||||||
|
// Check if inputPath is under YAO_ROOT
|
||||||
|
if rel, err := filepath.Rel(yaoRoot, inputPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||||
|
relPath = rel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse up from YAO_ROOT + relPath
|
||||||
|
dir = filepath.Join(yaoRoot, filepath.Dir(relPath))
|
||||||
|
for {
|
||||||
|
packagePath := filepath.Join(dir, "package.yao")
|
||||||
|
if _, err := os.Stat(packagePath); err == nil {
|
||||||
|
// Found package.yao
|
||||||
|
return r.loadAgentFromPath(dir, packagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to parent directory, but don't go above YAO_ROOT
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir || !strings.HasPrefix(parent, yaoRoot) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath)
|
return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +212,8 @@ func ValidateOptions(opts *Options) error {
|
||||||
|
|
||||||
// For file mode, check input file exists
|
// For file mode, check input file exists
|
||||||
if opts.InputMode == InputModeFile {
|
if opts.InputMode == InputModeFile {
|
||||||
if _, err := os.Stat(opts.Input); os.IsNotExist(err) {
|
resolvedPath := ResolvePathWithYaoRoot(opts.Input)
|
||||||
|
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("input file not found: %s", opts.Input)
|
return fmt.Errorf("input file not found: %s", opts.Input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +330,9 @@ func MergeOptions(opts *Options, defaults *Options) *Options {
|
||||||
// Format: {input_directory}/output-{timestamp}.jsonl
|
// Format: {input_directory}/output-{timestamp}.jsonl
|
||||||
// Timestamp format: YYYYMMDDHHMMSS
|
// Timestamp format: YYYYMMDDHHMMSS
|
||||||
func GenerateDefaultOutputPath(inputPath string) string {
|
func GenerateDefaultOutputPath(inputPath string) string {
|
||||||
dir := filepath.Dir(inputPath)
|
// Resolve input path considering YAO_ROOT
|
||||||
|
resolvedPath := ResolvePathWithYaoRoot(inputPath)
|
||||||
|
dir := filepath.Dir(resolvedPath)
|
||||||
timestamp := time.Now().Format("20060102150405")
|
timestamp := time.Now().Format("20060102150405")
|
||||||
filename := fmt.Sprintf("output-%s.jsonl", timestamp)
|
filename := fmt.Sprintf("output-%s.jsonl", timestamp)
|
||||||
return filepath.Join(dir, filename)
|
return filepath.Join(dir, filename)
|
||||||
|
|
@ -328,3 +363,19 @@ func CreateTestCaseFromMessage(message string) *Case {
|
||||||
Input: message,
|
Input: message,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePathWithYaoRoot resolves a file path relative to current directory
|
||||||
|
// No fallback to YAO_ROOT - paths are always resolved from current working directory
|
||||||
|
func ResolvePathWithYaoRoot(path string) string {
|
||||||
|
// If path is absolute, return as-is
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative to current directory
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return absPath
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
stdContext "context"
|
stdContext "context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -105,8 +106,9 @@ func (r *Executor) RunDirect() (*Report, error) {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ctx.Context = timeoutCtx
|
ctx.Context = timeoutCtx
|
||||||
|
|
||||||
// Parse input to messages
|
// Parse input to messages with file loading support
|
||||||
messages, err := tc.GetMessages()
|
inputOpts := r.getInputOptions()
|
||||||
|
messages, err := tc.GetMessagesWithOptions(inputOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse input: %w", err)
|
return nil, fmt.Errorf("failed to parse input: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -320,8 +322,10 @@ func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID str
|
||||||
Options: tc.Options,
|
Options: tc.Options,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse input to messages
|
// Parse input to messages with file loading support
|
||||||
messages, err := tc.GetMessages()
|
// BaseDir is derived from the input file directory
|
||||||
|
inputOpts := r.getInputOptions()
|
||||||
|
messages, err := tc.GetMessagesWithOptions(inputOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Status = StatusError
|
result.Status = StatusError
|
||||||
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
|
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
|
||||||
|
|
@ -613,3 +617,19 @@ func validateOutput(actual, expected interface{}) bool {
|
||||||
|
|
||||||
return string(actualJSON) == string(expectedJSON)
|
return string(actualJSON) == string(expectedJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInputOptions returns InputOptions based on the runner configuration
|
||||||
|
// BaseDir is derived from the input file directory (for file mode) or current working directory
|
||||||
|
func (r *Executor) getInputOptions() *InputOptions {
|
||||||
|
opts := &InputOptions{}
|
||||||
|
|
||||||
|
// For file mode, use the input file's directory as base
|
||||||
|
if r.opts.InputMode == InputModeFile && r.opts.Input != "" {
|
||||||
|
// Resolve path considering YAO_ROOT
|
||||||
|
resolvedPath := ResolvePathWithYaoRoot(r.opts.Input)
|
||||||
|
opts.BaseDir = filepath.Dir(resolvedPath)
|
||||||
|
}
|
||||||
|
// For message mode, BaseDir remains empty (uses current working directory)
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,13 @@ func (tc *Case) GetMessages() ([]context.Message, error) {
|
||||||
return ParseInput(tc.Input)
|
return ParseInput(tc.Input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMessagesWithOptions converts the Input to a slice of context.Message with options
|
||||||
|
// This handles all input formats: string, Message, []Message
|
||||||
|
// It also processes file:// references in content parts
|
||||||
|
func (tc *Case) GetMessagesWithOptions(opts *InputOptions) ([]context.Message, error) {
|
||||||
|
return ParseInputWithOptions(tc.Input, opts)
|
||||||
|
}
|
||||||
|
|
||||||
// GetTimeout returns the timeout duration for this test case
|
// GetTimeout returns the timeout duration for this test case
|
||||||
// Returns the override timeout if set, otherwise returns the default
|
// Returns the override timeout if set, otherwise returns the default
|
||||||
func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration {
|
func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration {
|
||||||
|
|
|
||||||
61
bin/yao-dev
Executable file
61
bin/yao-dev
Executable file
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# yao-dev - Run yao from source for real-time debugging
|
||||||
|
# Uses go -C to compile from source while keeping current directory as app root
|
||||||
|
|
||||||
|
# Get the real path of this script (resolve symlinks)
|
||||||
|
SCRIPT_PATH="${BASH_SOURCE[0]}"
|
||||||
|
while [ -L "$SCRIPT_PATH" ]; do
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||||
|
SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
|
||||||
|
# If the link is relative, resolve it relative to the directory
|
||||||
|
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||||
|
|
||||||
|
# YAO source directory is the parent of bin/
|
||||||
|
YAO_SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
if [ ! -d "$YAO_SOURCE_DIR" ]; then
|
||||||
|
echo "Error: yao source directory not found at $YAO_SOURCE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$YAO_SOURCE_DIR/go.mod" ]; then
|
||||||
|
echo "Error: go.mod not found in $YAO_SOURCE_DIR, not a valid yao source directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find app root by looking for app.yao in current directory or parent directories
|
||||||
|
find_app_root() {
|
||||||
|
local dir="$(pwd)"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -f "$dir/app.yao" ] || [ -f "$dir/app.json" ] || [ -f "$dir/app.jsonc" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
# If not found, use current directory as fallback
|
||||||
|
pwd
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set YAO_ROOT to app root directory
|
||||||
|
export YAO_ROOT="$(find_app_root)"
|
||||||
|
|
||||||
|
# Convert relative file paths in arguments to absolute paths
|
||||||
|
# This is needed because go -C changes the working directory
|
||||||
|
ARGS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
# Check if it looks like a relative path
|
||||||
|
if [[ "$arg" == ./* ]] || [[ "$arg" == ../* ]]; then
|
||||||
|
# Always convert to absolute path based on current directory
|
||||||
|
ARGS+=("$(pwd)/${arg#./}")
|
||||||
|
else
|
||||||
|
ARGS+=("$arg")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Use go -C to run from source directory while staying in current directory
|
||||||
|
exec go -C "$YAO_SOURCE_DIR" run . "${ARGS[@]}"
|
||||||
|
|
||||||
|
|
@ -52,6 +52,7 @@ func L(words string) string {
|
||||||
|
|
||||||
// Boot sets the configuration
|
// Boot sets the configuration
|
||||||
func Boot() {
|
func Boot() {
|
||||||
|
// Use root from Init() unless appPath is explicitly specified
|
||||||
root := config.Conf.Root
|
root := config.Conf.Root
|
||||||
if appPath != "" {
|
if appPath != "" {
|
||||||
r, err := filepath.Abs(appPath)
|
r, err := filepath.Abs(appPath)
|
||||||
|
|
@ -60,15 +61,13 @@ func Boot() {
|
||||||
}
|
}
|
||||||
root = r
|
root = r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load .env file, preserving the correct root
|
||||||
if envFile != "" {
|
if envFile != "" {
|
||||||
config.Conf = config.LoadFrom(envFile)
|
config.Conf = config.LoadFromWithRoot(envFile, root)
|
||||||
} else {
|
} else {
|
||||||
config.Conf = config.LoadFrom(filepath.Join(root, ".env"))
|
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Conf.Mode == "production" {
|
config.ApplyMode()
|
||||||
config.Production()
|
|
||||||
} else if config.Conf.Mode == "development" {
|
|
||||||
config.Development()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,52 +32,106 @@ func init() {
|
||||||
|
|
||||||
// Init setting
|
// Init setting
|
||||||
func Init() {
|
func Init() {
|
||||||
|
// Determine app root: YAO_ROOT env > find app.yao > current directory
|
||||||
|
root := os.Getenv("YAO_ROOT")
|
||||||
|
if root == "" {
|
||||||
|
root = findAppRoot()
|
||||||
|
}
|
||||||
|
if root == "" {
|
||||||
|
root = "."
|
||||||
|
}
|
||||||
|
|
||||||
filename, _ := filepath.Abs(filepath.Join(".", ".env"))
|
filename := filepath.Join(root, ".env")
|
||||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||||
Conf = Load()
|
Conf = LoadWithRoot(root)
|
||||||
if Conf.Mode == "production" {
|
ApplyMode()
|
||||||
Production()
|
|
||||||
} else if Conf.Mode == "development" {
|
|
||||||
Development()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Conf = LoadFrom(filename)
|
// Load .env then override root if auto-detected
|
||||||
if Conf.Mode == "production" {
|
Conf = LoadFromWithRoot(filename, root)
|
||||||
|
ApplyMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMode applies production or development mode based on Conf.Mode
|
||||||
|
func ApplyMode() {
|
||||||
|
switch Conf.Mode {
|
||||||
|
case "production":
|
||||||
Production()
|
Production()
|
||||||
} else if Conf.Mode == "development" {
|
case "development":
|
||||||
Development()
|
Development()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findAppRoot finds the Yao application root directory by looking for app.yao
|
||||||
|
// It traverses up from the current directory until it finds app.yao or reaches the filesystem root
|
||||||
|
func findAppRoot() string {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Check for app.yao, app.json, or app.jsonc
|
||||||
|
for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} {
|
||||||
|
appFilePath := filepath.Join(dir, appFile)
|
||||||
|
if _, err := os.Stat(appFilePath); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to parent directory
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
// Reached root, no app.yao found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// LoadFrom 从配置项中加载
|
// LoadFrom 从配置项中加载
|
||||||
func LoadFrom(envfile string) Config {
|
func LoadFrom(envfile string) Config {
|
||||||
|
return LoadFromWithRoot(envfile, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromWithRoot loads config from env file with optional root override
|
||||||
|
func LoadFromWithRoot(envfile string, root string) Config {
|
||||||
file, err := filepath.Abs(envfile)
|
file, err := filepath.Abs(envfile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg := Load()
|
cfg := LoadWithRoot(root)
|
||||||
ReloadLog()
|
ReloadLog()
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// load from env
|
// load from env
|
||||||
godotenv.Overload(file)
|
godotenv.Overload(file)
|
||||||
cfg := Load()
|
cfg := LoadWithRoot(root)
|
||||||
ReloadLog()
|
ReloadLog()
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the config
|
// Load the config
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
|
return LoadWithRoot("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithRoot loads config with an optional root override
|
||||||
|
// If root is empty, uses YAO_ROOT env or current directory
|
||||||
|
func LoadWithRoot(root string) Config {
|
||||||
cfg := Config{}
|
cfg := Config{}
|
||||||
if err := env.Parse(&cfg); err != nil {
|
if err := env.Parse(&cfg); err != nil {
|
||||||
exception.New("Can't read config %s", 500, err.Error()).Throw()
|
exception.New("Can't read config %s", 500, err.Error()).Throw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root path
|
// Root path: use provided root > env YAO_ROOT > default "."
|
||||||
cfg.Root, _ = filepath.Abs(cfg.Root)
|
if root != "" {
|
||||||
|
cfg.Root, _ = filepath.Abs(root)
|
||||||
|
} else {
|
||||||
|
cfg.Root, _ = filepath.Abs(cfg.Root)
|
||||||
|
}
|
||||||
|
|
||||||
// App Root
|
// App Root
|
||||||
if cfg.AppSource == "" {
|
if cfg.AppSource == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue