diff --git a/agent/output/jsapi/README.md b/agent/output/jsapi/README.md new file mode 100644 index 00000000..5aabb03f --- /dev/null +++ b/agent/output/jsapi/README.md @@ -0,0 +1,482 @@ +# Output JSAPI + +The Output JSAPI provides a JavaScript interface for sending output messages to clients from scripts (e.g., hooks, processes). It wraps the Go `output` package functionality and provides a convenient API for sending messages and message groups. + +## Overview + +The Output object allows you to: + +- Send individual messages to clients in various formats (text, error, loading, etc.) +- Send groups of related messages +- Support streaming with delta updates +- Handle different message types with custom properties + +## Constructor + +### `new Output(ctx)` + +Creates a new Output instance. + +**Parameters:** + +- `ctx` (Context): The agent context object + +**Returns:** + +- Output instance + +**Example:** + +```javascript +function Create(ctx, messages) { + const output = new Output(ctx); + // Use output methods... +} +``` + +## Methods + +### `Send(message)` + +Sends a single message to the client. + +**Parameters:** + +- `message` (string | object): The message to send + - If string: Automatically converted to a text message + - If object: Must have a `type` field and optional `props` and other fields + +**Returns:** + +- Output instance (for chaining) + +**Message Object Structure:** + +```javascript +{ + type: string, // Required: Message type (e.g., "text", "error", "loading") + props: object, // Optional: Message properties (type-specific) + id: string, // Optional: Message ID (for streaming) + delta: boolean, // Optional: Whether this is a delta update + done: boolean, // Optional: Whether the message is complete + delta_path: string, // Optional: Path for delta updates (e.g., "content") + delta_action: string, // Optional: Delta action ("append", "replace", "merge", "set") + type_change: boolean, // Optional: Whether this is a type correction + group_id: string, // Optional: Parent message group ID + group_start: boolean, // Optional: Marks the start of a message group + group_end: boolean, // Optional: Marks the end of a message group + metadata: { // Optional: Message metadata + timestamp: number, + sequence: number, + trace_id: string + } +} +``` + +**Examples:** + +Send a simple text message (shorthand): + +```javascript +output.Send("Hello, world!"); +``` + +Send a text message (full): + +```javascript +output.Send({ + type: "text", + props: { + content: "Hello, world!", + }, +}); +``` + +Send an error message: + +```javascript +output.Send({ + type: "error", + props: { + message: "Something went wrong", + code: "ERR_001", + details: "Additional error details", + }, +}); +``` + +Send a loading indicator: + +```javascript +output.Send({ + type: "loading", + props: { + message: "Searching knowledge base...", + }, +}); +``` + +Send streaming text with delta updates: + +```javascript +// First chunk +output.Send({ + type: "text", + id: "msg-1", + props: { content: "Hello" }, + delta: true, + done: false, +}); + +// Subsequent chunks +output.Send({ + type: "text", + id: "msg-1", + props: { content: " world" }, + delta: true, + delta_path: "content", + delta_action: "append", + done: false, +}); + +// Final chunk +output.Send({ + type: "text", + id: "msg-1", + props: { content: "!" }, + delta: true, + delta_path: "content", + delta_action: "append", + done: true, +}); +``` + +Chain multiple sends: + +```javascript +output + .Send("First message") + .Send("Second message") + .Send({ type: "loading", props: { message: "Processing..." } }); +``` + +### `SendGroup(group)` + +Sends a group of related messages. + +**Parameters:** + +- `group` (object): The message group + - `id` (string): Required - Group ID + - `messages` (array): Required - Array of message objects + - `metadata` (object): Optional - Group metadata + +**Returns:** + +- Output instance (for chaining) + +**Group Object Structure:** + +```javascript +{ + id: string, // Required: Message group ID + messages: [ // Required: Array of messages + { + type: string, + props: object, + // ... other message fields + } + ], + metadata: { // Optional: Group metadata + timestamp: number, + sequence: number, + trace_id: string + } +} +``` + +**Examples:** + +Send a simple message group: + +```javascript +output.SendGroup({ + id: "search-results", + messages: [ + { type: "text", props: { content: "Found 3 results:" } }, + { type: "text", props: { content: "Result 1" } }, + { type: "text", props: { content: "Result 2" } }, + { type: "text", props: { content: "Result 3" } }, + ], +}); +``` + +Send a group with metadata: + +```javascript +output.SendGroup({ + id: "analysis-group", + messages: [ + { type: "loading", props: { message: "Analyzing data..." } }, + { type: "text", props: { content: "Analysis complete" } }, + ], + metadata: { + timestamp: Date.now(), + sequence: 1, + trace_id: "trace-123", + }, +}); +``` + +## Built-in Message Types + +The Output JSAPI supports all built-in message types defined in the output package: + +### User Interaction Types + +- **`user_input`**: User input message (frontend display only) + ```javascript + { type: "user_input", props: { content: "User's message", role: "user" } } + ``` + +### Content Types + +- **`text`**: Plain text or Markdown content + + ```javascript + { type: "text", props: { content: "Hello **world**" } } + ``` + +- **`thinking`**: Reasoning/thinking process (e.g., o1 models) + + ```javascript + { type: "thinking", props: { content: "Let me think about this..." } } + ``` + +- **`loading`**: Loading/processing indicator + + ```javascript + { type: "loading", props: { message: "Processing..." } } + ``` + +- **`tool_call`**: LLM tool/function call + + ```javascript + { + type: "tool_call", + props: { + id: "call_123", + name: "search", + arguments: "{\"query\":\"test\"}" + } + } + ``` + +- **`error`**: Error message + ```javascript + { + type: "error", + props: { + message: "Error occurred", + code: "ERR_001", + details: "More info" + } + } + ``` + +### Media Types + +- **`image`**: Image content + + ```javascript + { + type: "image", + props: { + url: "https://example.com/image.jpg", + alt: "Description", + width: 800, + height: 600 + } + } + ``` + +- **`audio`**: Audio content + + ```javascript + { + type: "audio", + props: { + url: "https://example.com/audio.mp3", + format: "mp3", + duration: 120.5 + } + } + ``` + +- **`video`**: Video content + ```javascript + { + type: "video", + props: { + url: "https://example.com/video.mp4", + format: "mp4", + duration: 300 + } + } + ``` + +### System Types + +- **`action`**: System action (silent in OpenAI clients) + + ```javascript + { + type: "action", + props: { + name: "open_panel", + payload: { panel_id: "settings" } + } + } + ``` + +- **`event`**: Lifecycle event (CUI only, silent in OpenAI clients) + ```javascript + { + type: "event", + props: { + event: "stream_start", + message: "Starting stream..." + } + } + ``` + +## Usage in Hooks + +### Create Hook Example + +```javascript +/** + * Create hook - Called before sending messages to the LLM + * @param {Context} ctx - Agent context + * @param {Array} messages - User messages + * @returns {Object} Hook response + */ +function Create(ctx, messages) { + const output = new Output(ctx); + + // Send a loading indicator + output.Send({ + type: "loading", + props: { message: "Processing your request..." }, + }); + + // Send custom messages to the user + output.Send({ + type: "text", + props: { content: "I'm thinking about your question..." }, + }); + + // Return hook response + return { + messages: messages, + temperature: 0.7, + }; +} +``` + +### Done Hook Example + +```javascript +/** + * Done hook - Called after assistant completes response + * @param {Context} ctx - Agent context + * @param {Array} messages - Conversation messages + * @param {Object} response - Assistant response + */ +function Done(ctx, messages, response) { + const output = new Output(ctx); + + // Send a completion message + output.Send({ + type: "text", + props: { content: "Response complete!" }, + }); + + // Send an action + output.Send({ + type: "action", + props: { + name: "save_conversation", + payload: { chat_id: ctx.chat_id }, + }, + }); +} +``` + +### Progress Updates Example + +```javascript +function ProcessData(ctx, data) { + const output = new Output(ctx); + + // Show progress + const steps = ["Loading", "Processing", "Analyzing", "Complete"]; + + for (let i = 0; i < steps.length; i++) { + output.Send({ + type: "loading", + props: { + message: `${steps[i]}... (${i + 1}/${steps.length})`, + }, + }); + + // Do some work... + processStep(i); + } + + // Send final result + output.Send({ + type: "text", + props: { content: "All done!" }, + }); +} +``` + +## Error Handling + +The Output JSAPI throws exceptions for invalid parameters: + +```javascript +try { + const output = new Output(ctx); + + // This will throw: message.type is required + output.Send({ props: { content: "test" } }); +} catch (e) { + console.error("Output error:", e.toString()); +} +``` + +Common errors: + +- `"Output constructor requires a context argument"` - Missing ctx parameter +- `"Send requires a message argument"` - Missing message parameter +- `"message.type is required and must be a string"` - Missing or invalid type field +- `"SendGroup requires a group argument"` - Missing group parameter +- `"group.id is required and must be a string"` - Missing group ID +- `"group.messages is required and must be an array"` - Missing or invalid messages array + +## Notes + +1. **Context Requirement**: The Output object must be created with a valid agent context +2. **Writer Required**: The context must have a Writer set (automatically handled in API requests) +3. **Message Format**: Messages are automatically adapted based on the context's Accept type (standard, cui-web, cui-native, cui-desktop) +4. **Streaming**: For streaming responses, use delta updates with proper message IDs +5. **Method Chaining**: All methods return the Output instance for convenient chaining + +## See Also + +- [Output Package Documentation](../README.md) +- [Message Types](../BUILTIN_TYPES.md) +- [Agent Context](../../context/README.md) +- [Hook System](../../assistant/hook/README.md) diff --git a/agent/output/jsapi/output.go b/agent/output/jsapi/output.go new file mode 100644 index 00000000..07acbbec --- /dev/null +++ b/agent/output/jsapi/output.go @@ -0,0 +1,412 @@ +package jsapi + +import ( + "fmt" + + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output" + "github.com/yaoapp/yao/agent/output/message" + "rogchap.com/v8go" +) + +func init() { + // Auto-register Output JavaScript API when package is imported + v8.RegisterFunction("Output", ExportFunction) +} + +// Usage from JavaScript: +// +// const output = new Output(ctx) +// output.Send({ type: "text", props: { content: "Hello" } }) +// output.Send("Hello") // shorthand for text message +// output.SendGroup({ id: "group1", messages: [...] }) +// +// Objects: +// - Output: Output manager (constructor) + +// ExportFunction exports the Output constructor function template +// This is used by v8.RegisterFunction +func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, outputConstructor) +} + +// outputConstructor is the JavaScript constructor for Output +// Usage: new Output(ctx) +func outputConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + // Require ctx argument + if len(args) < 1 { + return bridge.JsException(v8ctx, "Output constructor requires a context argument") + } + + // Get the context object from JavaScript + ctxObj, err := args[0].AsObject() + if err != nil { + return bridge.JsException(v8ctx, fmt.Sprintf("context must be an object: %s", err)) + } + + // Get the goValueID from internal field (index 0) + if ctxObj.InternalFieldCount() < 1 { + return bridge.JsException(v8ctx, "context object is missing internal fields") + } + + goValueIDValue := ctxObj.GetInternalField(0) + if goValueIDValue == nil || !goValueIDValue.IsString() { + return bridge.JsException(v8ctx, "context object is missing goValueID") + } + + goValueID := goValueIDValue.String() + + // Retrieve the Go context object from bridge registry + goObj := bridge.GetGoObject(goValueID) + if goObj == nil { + return bridge.JsException(v8ctx, "context object not found in registry") + } + + // Type assert to *agentContext.Context + ctx, ok := goObj.(*agentContext.Context) + if !ok { + return bridge.JsException(v8ctx, fmt.Sprintf("object is not a Context, got %T", goObj)) + } + + // Create output object + outputObj, err := NewOutputObject(v8ctx, ctx) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + + return outputObj +} + +// NewOutputObject creates a JavaScript Output object +func NewOutputObject(v8ctx *v8go.Context, ctx *agentContext.Context) (*v8go.Value, error) { + jsObject := v8go.NewObjectTemplate(v8ctx.Isolate()) + + // Set internal field count to 1 to store the __go_id + // Internal fields are not accessible from JavaScript, providing better security + jsObject.SetInternalFieldCount(1) + + // Register context in global bridge registry for efficient Go object retrieval + // The goValueID will be stored in internal field (index 0) after instance creation + goValueID := bridge.RegisterGoObject(ctx) + + // Set methods + jsObject.Set("Send", outputSendMethod(v8ctx.Isolate(), ctx)) + jsObject.Set("SendGroup", outputSendGroupMethod(v8ctx.Isolate(), ctx)) + + // Set release function that will be called when JavaScript object is released + jsObject.Set("__release", outputGoRelease(v8ctx.Isolate())) + + // Create instance + instance, err := jsObject.NewInstance(v8ctx) + if err != nil { + // Clean up: release from global registry if instance creation failed + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + // Store the goValueID in internal field (index 0) + // This is not accessible from JavaScript, providing better security + obj, err := instance.Value.AsObject() + if err != nil { + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + err = obj.SetInternalField(0, goValueID) + if err != nil { + bridge.ReleaseGoObject(goValueID) + return nil, err + } + + return instance.Value, nil +} + +// outputGoRelease releases the Go object from the global bridge registry +// It retrieves the goValueID from internal field (index 0) and releases the Go object +func outputGoRelease(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + // Get the output object (this) + thisObj, err := info.This().AsObject() + if err == nil && thisObj.InternalFieldCount() > 0 { + // Get goValueID from internal field (index 0) + goValueIDValue := thisObj.GetInternalField(0) + if goValueIDValue != nil && goValueIDValue.IsString() { + goValueID := goValueIDValue.String() + // Release from global bridge registry + bridge.ReleaseGoObject(goValueID) + } + } + + return v8go.Undefined(info.Context().Isolate()) + }) +} + +// outputSendMethod implements the Send method +// Usage: output.Send(message) +// message can be an object with { type: string, props: object, ... } or a simple string (will be converted to text message) +func outputSendMethod(iso *v8go.Isolate, ctx *agentContext.Context) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(v8ctx, "Send requires a message argument") + } + + // Parse message argument + msg, err := parseMessage(v8ctx, args[0]) + if err != nil { + return bridge.JsException(v8ctx, fmt.Sprintf("invalid message: %s", err)) + } + + // Call output.Send + if err := output.Send(ctx, msg); err != nil { + return bridge.JsException(v8ctx, fmt.Sprintf("Send failed: %s", err)) + } + + return info.This().Value + }) +} + +// outputSendGroupMethod implements the SendGroup method +// Usage: output.SendGroup(group) +// group must be an object with { id: string, messages: [], ... } +func outputSendGroupMethod(iso *v8go.Isolate, ctx *agentContext.Context) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if len(args) < 1 { + return bridge.JsException(v8ctx, "SendGroup requires a group argument") + } + + // Parse group argument + group, err := parseGroup(v8ctx, args[0]) + if err != nil { + return bridge.JsException(v8ctx, fmt.Sprintf("invalid group: %s", err)) + } + + // Call output.SendGroup + if err := output.SendGroup(ctx, group); err != nil { + return bridge.JsException(v8ctx, fmt.Sprintf("SendGroup failed: %s", err)) + } + + return info.This().Value + }) +} + +// parseMessage parses a JavaScript value into a message.Message +func parseMessage(v8ctx *v8go.Context, jsValue *v8go.Value) (*message.Message, error) { + // Handle string shorthand: convert to text message + if jsValue.IsString() { + return &message.Message{ + Type: message.TypeText, + Props: map[string]interface{}{ + "content": jsValue.String(), + }, + }, nil + } + + // Handle object + if !jsValue.IsObject() { + return nil, fmt.Errorf("message must be a string or object") + } + + // Convert to Go map + goValue, err := bridge.GoValue(jsValue, v8ctx) + if err != nil { + return nil, fmt.Errorf("failed to convert message: %w", err) + } + + msgMap, ok := goValue.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("message must be an object") + } + + // Build message + msg := &message.Message{} + + // Type field (required) + if msgType, ok := msgMap["type"].(string); ok { + msg.Type = msgType + } else { + return nil, fmt.Errorf("message.type is required and must be a string") + } + + // Props field (optional) + if props, ok := msgMap["props"].(map[string]interface{}); ok { + msg.Props = props + } + + // Optional fields + if id, ok := msgMap["id"].(string); ok { + msg.ID = id + } + if delta, ok := msgMap["delta"].(bool); ok { + msg.Delta = delta + } + if done, ok := msgMap["done"].(bool); ok { + msg.Done = done + } + if deltaPath, ok := msgMap["delta_path"].(string); ok { + msg.DeltaPath = deltaPath + } + if deltaAction, ok := msgMap["delta_action"].(string); ok { + msg.DeltaAction = deltaAction + } + if typeChange, ok := msgMap["type_change"].(bool); ok { + msg.TypeChange = typeChange + } + if groupID, ok := msgMap["group_id"].(string); ok { + msg.GroupID = groupID + } + if groupStart, ok := msgMap["group_start"].(bool); ok { + msg.GroupStart = groupStart + } + if groupEnd, ok := msgMap["group_end"].(bool); ok { + msg.GroupEnd = groupEnd + } + + // Metadata (optional) + if metadataMap, ok := msgMap["metadata"].(map[string]interface{}); ok { + metadata := &message.Metadata{} + if timestamp, ok := metadataMap["timestamp"].(float64); ok { + metadata.Timestamp = int64(timestamp) + } + if sequence, ok := metadataMap["sequence"].(float64); ok { + metadata.Sequence = int(sequence) + } + if traceID, ok := metadataMap["trace_id"].(string); ok { + metadata.TraceID = traceID + } + msg.Metadata = metadata + } + + return msg, nil +} + +// parseGroup parses a JavaScript value into a message.Group +func parseGroup(v8ctx *v8go.Context, jsValue *v8go.Value) (*message.Group, error) { + // Must be an object + if !jsValue.IsObject() { + return nil, fmt.Errorf("group must be an object") + } + + // Convert to Go map + goValue, err := bridge.GoValue(jsValue, v8ctx) + if err != nil { + return nil, fmt.Errorf("failed to convert group: %w", err) + } + + groupMap, ok := goValue.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("group must be an object") + } + + // Build group + group := &message.Group{} + + // ID field (required) + if id, ok := groupMap["id"].(string); ok { + group.ID = id + } else { + return nil, fmt.Errorf("group.id is required and must be a string") + } + + // Messages field (required) + if messagesArray, ok := groupMap["messages"].([]interface{}); ok { + group.Messages = make([]*message.Message, 0, len(messagesArray)) + for i, msgInterface := range messagesArray { + // Convert to map + msgMap, ok := msgInterface.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("group.messages[%d] must be an object", i) + } + + // Convert map to Message + msg := &message.Message{} + + // Type field (required) + if msgType, ok := msgMap["type"].(string); ok { + msg.Type = msgType + } else { + return nil, fmt.Errorf("group.messages[%d].type is required", i) + } + + // Props field (optional) + if props, ok := msgMap["props"].(map[string]interface{}); ok { + msg.Props = props + } + + // Optional fields + if id, ok := msgMap["id"].(string); ok { + msg.ID = id + } + if delta, ok := msgMap["delta"].(bool); ok { + msg.Delta = delta + } + if done, ok := msgMap["done"].(bool); ok { + msg.Done = done + } + if deltaPath, ok := msgMap["delta_path"].(string); ok { + msg.DeltaPath = deltaPath + } + if deltaAction, ok := msgMap["delta_action"].(string); ok { + msg.DeltaAction = deltaAction + } + if typeChange, ok := msgMap["type_change"].(bool); ok { + msg.TypeChange = typeChange + } + if groupID, ok := msgMap["group_id"].(string); ok { + msg.GroupID = groupID + } + if groupStart, ok := msgMap["group_start"].(bool); ok { + msg.GroupStart = groupStart + } + if groupEnd, ok := msgMap["group_end"].(bool); ok { + msg.GroupEnd = groupEnd + } + + // Metadata (optional) + if metadataMap, ok := msgMap["metadata"].(map[string]interface{}); ok { + metadata := &message.Metadata{} + if timestamp, ok := metadataMap["timestamp"].(float64); ok { + metadata.Timestamp = int64(timestamp) + } + if sequence, ok := metadataMap["sequence"].(float64); ok { + metadata.Sequence = int(sequence) + } + if traceID, ok := metadataMap["trace_id"].(string); ok { + metadata.TraceID = traceID + } + msg.Metadata = metadata + } + + group.Messages = append(group.Messages, msg) + } + } else { + return nil, fmt.Errorf("group.messages is required and must be an array") + } + + // Metadata (optional) + if metadataMap, ok := groupMap["metadata"].(map[string]interface{}); ok { + metadata := &message.Metadata{} + if timestamp, ok := metadataMap["timestamp"].(float64); ok { + metadata.Timestamp = int64(timestamp) + } + if sequence, ok := metadataMap["sequence"].(float64); ok { + metadata.Sequence = int(sequence) + } + if traceID, ok := metadataMap["trace_id"].(string); ok { + metadata.TraceID = traceID + } + group.Metadata = metadata + } + + return group, nil +} diff --git a/agent/output/jsapi/output_test.go b/agent/output/jsapi/output_test.go new file mode 100644 index 00000000..535ec035 --- /dev/null +++ b/agent/output/jsapi/output_test.go @@ -0,0 +1,361 @@ +package jsapi + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + v8 "github.com/yaoapp/gou/runtime/v8" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) + +func TestOutputConstructor(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + tests := []struct { + name string + script string + expectError bool + }{ + { + name: "Create Output with context", + script: ` + function test(ctx) { + const output = new Output(ctx); + return output !== undefined && output !== null; + } + `, + expectError: false, + }, + { + name: "Create Output without context should fail", + script: ` + function test(ctx) { + try { + const output = new Output(); + return false; + } catch (e) { + return e.toString().includes("context argument"); + } + } + `, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := agentContext.New(context.Background(), nil, "test-chat-123", "") + ctx.AssistantID = "test-assistant-456" + + // Execute test script with v8.Call + res, err := v8.Call(v8.CallOptions{}, tt.script, &ctx) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.True(t, res.(bool)) + }) + } +} + +func TestOutputSend(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + tests := []struct { + name string + script string + expectError bool + validate func(*testing.T, *agentContext.Context) + }{ + { + name: "Send text message with object", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.Send({ + type: "text", + props: { content: "Hello World" } + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send text message with string shorthand", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.Send("Hello World"); + return true; + } + `, + expectError: false, + }, + { + name: "Send message with all fields", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.Send({ + type: "text", + props: { content: "Test" }, + id: "msg-1", + delta: true, + done: false, + delta_path: "content", + delta_action: "append", + metadata: { + timestamp: 1234567890, + sequence: 1, + trace_id: "trace-123" + } + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send error message", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.Send({ + type: "error", + props: { + message: "Something went wrong", + code: "ERR_001" + } + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send without message should fail", + script: ` + function test(ctx) { + try { + const output = new Output(ctx); + output.Send(); + return false; + } catch (e) { + return e.toString().includes("message argument"); + } + } + `, + expectError: false, + }, + { + name: "Send message without type should fail", + script: ` + function test(ctx) { + try { + const output = new Output(ctx); + output.Send({ props: { content: "test" } }); + return false; + } catch (e) { + return e.toString().includes("type is required"); + } + } + `, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create context with mock writer + ctx := agentContext.New(context.Background(), nil, "test-chat", "") + ctx.Writer = &mockWriter{} + + // Execute test script with v8.Call + res, err := v8.Call(v8.CallOptions{}, tt.script, &ctx) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.True(t, res.(bool)) + + if tt.validate != nil { + tt.validate(t, &ctx) + } + }) + } +} + +func TestOutputSendGroup(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + tests := []struct { + name string + script string + expectError bool + }{ + { + name: "Send message group", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.SendGroup({ + id: "group-1", + messages: [ + { type: "text", props: { content: "Message 1" } }, + { type: "text", props: { content: "Message 2" } } + ] + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send group with metadata", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.SendGroup({ + id: "group-1", + messages: [ + { type: "text", props: { content: "Test" } } + ], + metadata: { + timestamp: 1234567890, + sequence: 1 + } + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send empty group", + script: ` + function test(ctx) { + const output = new Output(ctx); + output.SendGroup({ + id: "group-1", + messages: [] + }); + return true; + } + `, + expectError: false, + }, + { + name: "Send group without id should fail", + script: ` + function test(ctx) { + try { + const output = new Output(ctx); + output.SendGroup({ + messages: [ + { type: "text", props: { content: "Test" } } + ] + }); + return false; + } catch (e) { + return e.toString().includes("id is required"); + } + } + `, + expectError: false, + }, + { + name: "Send group without messages should fail", + script: ` + function test(ctx) { + try { + const output = new Output(ctx); + output.SendGroup({ id: "group-1" }); + return false; + } catch (e) { + return e.toString().includes("messages is required"); + } + } + `, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create context with mock writer + ctx := agentContext.New(context.Background(), nil, "test-chat", "") + ctx.Writer = &mockWriter{} + + // Execute test script with v8.Call + res, err := v8.Call(v8.CallOptions{}, tt.script, &ctx) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.True(t, res.(bool)) + }) + } +} + +func TestOutputChaining(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + script := ` + function test(ctx) { + const output = new Output(ctx); + + // Send should return the output object for chaining + const result = output.Send("Message 1"); + + // Should be able to chain sends + output.Send("Message 2").Send("Message 3"); + + return result !== undefined; + } + ` + + ctx := agentContext.New(context.Background(), nil, "test-chat", "") + ctx.Writer = &mockWriter{} + + // Execute test script with v8.Call + res, err := v8.Call(v8.CallOptions{}, script, &ctx) + assert.NoError(t, err) + assert.True(t, res.(bool)) +} + +// mockWriter is a mock implementation of http.ResponseWriter for testing +type mockWriter struct { + data [][]byte + header http.Header +} + +func (w *mockWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *mockWriter) Write(p []byte) (n int, err error) { + w.data = append(w.data, p) + return len(p), nil +} + +func (w *mockWriter) WriteHeader(statusCode int) {} + +func (w *mockWriter) Flush() {} diff --git a/main.go b/main.go index 356fb79e..eb10398b 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( _ "github.com/yaoapp/gou/diff" _ "github.com/yaoapp/gou/encoding" + _ "github.com/yaoapp/yao/agent/output/jsapi" _ "github.com/yaoapp/yao/aigc" _ "github.com/yaoapp/yao/crypto" _ "github.com/yaoapp/yao/excel"