- Update `forceSkipForSubAgent` to respect caller's `skip.output` setting, allowing for silent internal worker agent execution. - Refactor error handling in `callAgentWithContext` to utilize a new `NewResult` function for consistent result construction. - Introduce `ProcessCallRequest` structure for improved agent call parameters, including a default timeout constant for process calls.
385 lines
12 KiB
Go
385 lines
12 KiB
Go
package caller_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yaoapp/gou/process"
|
|
"github.com/yaoapp/yao/agent/caller"
|
|
"github.com/yaoapp/yao/agent/testutils"
|
|
)
|
|
|
|
// newLLMProcess creates a process.Process with a 120s outer timeout for LLM calls.
|
|
// agent.Call has its own internal default timeout (DefaultProcessTimeout = 600s),
|
|
// but the outer context (120s) takes precedence via context.WithTimeout chaining.
|
|
func newLLMProcess(t *testing.T, name string, args ...interface{}) *process.Process {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
t.Cleanup(cancel)
|
|
return process.NewWithContext(ctx, name, args...)
|
|
}
|
|
|
|
// ============================================================================
|
|
// A. Pure LLM scenarios (tests.simple-greeting — no hooks)
|
|
// ============================================================================
|
|
|
|
func TestProcessCall_LLM_Basic(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM (source env.local.sh)")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Hello!"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
val := proc.Value()
|
|
require.NotNil(t, val, "process should return a value")
|
|
|
|
result, ok := val.(*caller.Result)
|
|
require.True(t, ok, "value should be *caller.Result, got %T", val)
|
|
|
|
assert.Equal(t, "tests.simple-greeting", result.AgentID)
|
|
assert.Empty(t, result.Error, "should not have error")
|
|
assert.NotEmpty(t, result.Content, "should have LLM content")
|
|
assert.NotNil(t, result.Response, "should have full response")
|
|
t.Logf("LLM response: %s", result.Content)
|
|
}
|
|
|
|
func TestProcessCall_LLM_MultipleMessages(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "system", "content": "Always reply in JSON format."},
|
|
map[string]interface{}{"role": "user", "content": "Say hello"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotEmpty(t, result.Content)
|
|
t.Logf("Multi-message response: %s", result.Content)
|
|
}
|
|
|
|
func TestProcessCall_LLM_WithMetadata(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Hi there!"},
|
|
},
|
|
"metadata": map[string]interface{}{"source": "e2e-test", "mode": "task"},
|
|
"locale": "zh-CN",
|
|
"route": "/test/e2e",
|
|
"chat_id": "e2e-test-chat-001",
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotEmpty(t, result.Content)
|
|
t.Logf("With-metadata response: %s", result.Content)
|
|
}
|
|
|
|
func TestProcessCall_LLM_SkipOutputForced(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Explicitly pass skip.output=false — headless context MUST force it to true
|
|
// If the force logic fails, this would panic (nil Writer).
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Hello!"},
|
|
},
|
|
"skip": map[string]interface{}{
|
|
"output": false,
|
|
"history": false,
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err, "should NOT panic even with skip.output=false — headless forces true")
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotEmpty(t, result.Content)
|
|
}
|
|
|
|
// ============================================================================
|
|
// B. Create Hook scenarios (tests.create)
|
|
// ============================================================================
|
|
|
|
func TestProcessCall_CreateHook_Default(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Send a generic message — Create Hook routes to scenarioDefault
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.create",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "hello world"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "tests.create", result.AgentID)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotEmpty(t, result.Content, "Create Hook should still produce LLM response")
|
|
t.Logf("CreateHook default response: %s", result.Content)
|
|
}
|
|
|
|
func TestProcessCall_CreateHook_ReturnFull(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Send "return_full" — Create Hook returns full HookCreateResponse with custom messages
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.create",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "return_full"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "tests.create", result.AgentID)
|
|
assert.Empty(t, result.Error)
|
|
// The Create Hook overrides messages with system + user, then LLM responds
|
|
assert.NotEmpty(t, result.Content)
|
|
t.Logf("CreateHook return_full response: %s", result.Content)
|
|
}
|
|
|
|
// ============================================================================
|
|
// C. Next Hook scenarios (tests.next)
|
|
// ============================================================================
|
|
|
|
func TestProcessCall_NextHook_Standard(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Send "standard" — Next Hook returns null, standard LLM response is used
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.next",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "standard"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "tests.next", result.AgentID)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotEmpty(t, result.Content, "standard scenario should return LLM content")
|
|
t.Logf("NextHook standard response: %s", result.Content)
|
|
}
|
|
|
|
func TestProcessCall_NextHook_CustomData(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Send "return_custom_data" — Next Hook returns custom data
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.next",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "return_custom_data"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err)
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "tests.next", result.AgentID)
|
|
assert.Empty(t, result.Error)
|
|
assert.NotNil(t, result.Response, "should have response")
|
|
|
|
// Next Hook custom data is available in response.Next
|
|
if result.Response != nil && result.Response.Next != nil {
|
|
t.Logf("NextHook custom data: %+v", result.Response.Next)
|
|
nextMap, ok := result.Response.Next.(map[string]interface{})
|
|
if ok {
|
|
// The Next Hook returns { data: { message, test, timestamp } }
|
|
if dataMap, ok := nextMap["data"].(map[string]interface{}); ok {
|
|
assert.Equal(t, "Custom response from Next Hook", dataMap["message"])
|
|
assert.Equal(t, true, dataMap["test"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// D. Timeout scenarios
|
|
// ============================================================================
|
|
|
|
func TestProcessCall_Timeout_Short(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires real LLM (source env.local.sh)")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Set timeout=2 seconds — LLM round-trip will certainly exceed this.
|
|
// Verifies that the timeout parameter is respected and produces an error.
|
|
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."},
|
|
},
|
|
"timeout": 2,
|
|
})
|
|
|
|
err := proc.Execute()
|
|
if err != nil {
|
|
// Timeout may surface as a process-level error (context deadline exceeded)
|
|
t.Logf("Process error (expected timeout): %s", err.Error())
|
|
assert.Contains(t, err.Error(), "deadline exceeded",
|
|
"error should indicate context deadline exceeded")
|
|
return
|
|
}
|
|
|
|
// Or the agent.Stream may catch the timeout and return it in Result.Error
|
|
val := proc.Value()
|
|
require.NotNil(t, val, "process should return a value")
|
|
result, ok := val.(*caller.Result)
|
|
require.True(t, ok, "value should be *caller.Result, got %T", val)
|
|
assert.NotEmpty(t, result.Error, "should have timeout error in result")
|
|
t.Logf("Timeout error in result: %s", result.Error)
|
|
}
|
|
|
|
// ============================================================================
|
|
// E. Error / validation scenarios
|
|
// ============================================================================
|
|
|
|
func TestProcessCall_Error_MissingAssistantID(t *testing.T) {
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := process.New("agent.call", map[string]interface{}{
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Hello"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.Error(t, err, "should fail: assistant_id is required")
|
|
t.Logf("Expected error: %s", err.Error())
|
|
assert.Contains(t, err.Error(), "assistant_id")
|
|
}
|
|
|
|
func TestProcessCall_Error_EmptyMessages(t *testing.T) {
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := process.New("agent.call", map[string]interface{}{
|
|
"assistant_id": "tests.simple-greeting",
|
|
"messages": []interface{}{},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.Error(t, err, "should fail: messages is required")
|
|
t.Logf("Expected error: %s", err.Error())
|
|
assert.Contains(t, err.Error(), "messages")
|
|
}
|
|
|
|
func TestProcessCall_Error_InvalidArgument(t *testing.T) {
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
// Pass a string instead of map — json.Marshal will succeed but Unmarshal will fail
|
|
proc := process.New("agent.call", "not-a-map")
|
|
|
|
err := proc.Execute()
|
|
require.Error(t, err, "should fail: argument must be a map")
|
|
t.Logf("Expected error: %s", err.Error())
|
|
}
|
|
|
|
func TestProcessCall_Error_NoArgument(t *testing.T) {
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := process.New("agent.call")
|
|
|
|
err := proc.Execute()
|
|
require.Error(t, err, "should fail: argument is required")
|
|
t.Logf("Expected error: %s", err.Error())
|
|
}
|
|
|
|
func TestProcessCall_Error_NonexistentAgent(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping: requires environment")
|
|
}
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
proc := process.New("agent.call", map[string]interface{}{
|
|
"assistant_id": "does.not.exist.agent",
|
|
"messages": []interface{}{
|
|
map[string]interface{}{"role": "user", "content": "Hello"},
|
|
},
|
|
})
|
|
|
|
err := proc.Execute()
|
|
require.NoError(t, err, "process should not error — error is in Result")
|
|
|
|
result, ok := proc.Value().(*caller.Result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "does.not.exist.agent", result.AgentID)
|
|
assert.NotEmpty(t, result.Error, "should have error for nonexistent agent")
|
|
assert.Contains(t, result.Error, "failed to get agent")
|
|
t.Logf("Expected error in result: %s", result.Error)
|
|
}
|