mirror of
https://github.com/google/adk-go.git
synced 2026-10-28 16:44:38 +08:00
840 lines
24 KiB
Go
840 lines
24 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package workflow
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"google.golang.org/genai"
|
|
|
|
"google.golang.org/adk/v2/agent"
|
|
"google.golang.org/adk/v2/session"
|
|
)
|
|
|
|
func TestNewFunctionNodeWithSchema(t *testing.T) {
|
|
type Input struct {
|
|
Value string `json:"value"`
|
|
}
|
|
type Output struct {
|
|
Result string `json:"result"`
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
nodeName string
|
|
fn func(ctx agent.Context, input Input) (map[string]any, error)
|
|
inputSchema *jsonschema.Schema
|
|
outputSchema *jsonschema.Schema
|
|
input any
|
|
wantOutput map[string]any
|
|
wantErr bool
|
|
errSubstr string
|
|
}{
|
|
{
|
|
name: "Success",
|
|
nodeName: "upper",
|
|
fn: func(ctx agent.Context, input Input) (map[string]any, error) {
|
|
return map[string]any{"result": strings.ToUpper(input.Value)}, nil
|
|
},
|
|
inputSchema: mustSchema[Input](t),
|
|
outputSchema: mustSchema[Output](t),
|
|
input: Input{Value: "hello"},
|
|
wantOutput: map[string]any{"result": "HELLO"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "NilInput",
|
|
nodeName: "nil_test",
|
|
fn: func(ctx agent.Context, input Input) (map[string]any, error) {
|
|
if input.Value == "" {
|
|
return map[string]any{"result": "zero"}, nil
|
|
}
|
|
return map[string]any{"result": "not-zero"}, nil
|
|
},
|
|
inputSchema: mustSchema[Input](t),
|
|
outputSchema: mustSchema[Output](t),
|
|
input: nil,
|
|
wantOutput: map[string]any{"result": "zero"},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
node, err := NewFunctionNodeWithSchema[Input, map[string]any](tc.nodeName, tc.fn, tc.inputSchema, tc.outputSchema, defaultNodeConfig)
|
|
if err != nil {
|
|
t.Fatalf("NewFunctionNodeWithSchema failed: %v", err)
|
|
}
|
|
|
|
mockCtx := &MockInvocationContext{Context: t.Context(), sess: nil}
|
|
exCtx := agent.NewContext(mockCtx)
|
|
events := node.Run(exCtx, tc.input)
|
|
|
|
count := 0
|
|
for ev, err := range events {
|
|
if err != nil {
|
|
if !tc.wantErr {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) {
|
|
t.Errorf("expected error containing %q, got %v", tc.errSubstr, err)
|
|
}
|
|
return // Expected error handled
|
|
}
|
|
count++
|
|
if tc.wantErr {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
if diff := cmp.Diff(tc.wantOutput, ev.Output); diff != "" {
|
|
t.Errorf("output mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
if !tc.wantErr && count != 1 {
|
|
t.Errorf("expected 1 event, got %d", count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFunctionNode_RunDoesNotValidate verifies Run yields the raw output
|
|
// unchanged even when it violates the output schema: validation is the
|
|
// scheduler's job (ValidateOutput), not Run's.
|
|
func TestFunctionNode_RunDoesNotValidate(t *testing.T) {
|
|
type Input struct {
|
|
Value string `json:"value"`
|
|
}
|
|
type TargetOutput struct {
|
|
Result int `json:"result"`
|
|
}
|
|
|
|
raw := map[string]any{"result": "not-an-int"} // violates TargetOutput
|
|
fn := func(ctx agent.Context, input Input) (map[string]any, error) {
|
|
return raw, nil
|
|
}
|
|
node, err := NewFunctionNodeWithSchema[Input, map[string]any](
|
|
"test", fn, mustSchema[Input](t), mustSchema[TargetOutput](t), defaultNodeConfig)
|
|
if err != nil {
|
|
t.Fatalf("NewFunctionNodeWithSchema failed: %v", err)
|
|
}
|
|
|
|
mockCtx := &MockInvocationContext{Context: t.Context(), sess: nil}
|
|
exCtx := agent.NewContext(mockCtx)
|
|
count := 0
|
|
for ev, err := range node.Run(exCtx, Input{Value: "hello"}) {
|
|
if err != nil {
|
|
t.Fatalf("Run returned unexpected error: %v", err)
|
|
}
|
|
if diff := cmp.Diff(raw, ev.Output); diff != "" {
|
|
t.Errorf("Run output mismatch (-want +got):\n%s", diff)
|
|
}
|
|
count++
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 event from Run, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestFunctionNode_ValidateOutput covers the scheduler-side output
|
|
// validation contract: a schema-conforming value passes through, a
|
|
// schema mismatch errors, and a nil schema accepts anything.
|
|
func TestFunctionNode_ValidateOutput(t *testing.T) {
|
|
type Input struct {
|
|
Value string `json:"value"`
|
|
}
|
|
type TargetOutput struct {
|
|
Result int `json:"result"`
|
|
}
|
|
|
|
fn := func(ctx agent.Context, input Input) (map[string]any, error) {
|
|
return nil, nil // body unused: ValidateOutput is exercised directly
|
|
}
|
|
schemaNode, err := NewFunctionNodeWithSchema[Input, map[string]any](
|
|
"test", fn, mustSchema[Input](t), mustSchema[TargetOutput](t), defaultNodeConfig)
|
|
if err != nil {
|
|
t.Fatalf("NewFunctionNodeWithSchema failed: %v", err)
|
|
}
|
|
nilSchemaNode := NewFunctionNode[Input, map[string]any]("test_nil", fn, defaultNodeConfig)
|
|
|
|
tests := []struct {
|
|
name string
|
|
node *FunctionNode
|
|
output any
|
|
want any
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "direct_valid_passes_through",
|
|
node: schemaNode,
|
|
output: map[string]any{"result": 1},
|
|
want: map[string]any{"result": 1},
|
|
},
|
|
{
|
|
name: "schema_mismatch_fails",
|
|
node: schemaNode,
|
|
output: map[string]any{"result": "not-an-int"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "nil_schema_passes_through",
|
|
node: nilSchemaNode,
|
|
output: map[string]any{"anything": 1},
|
|
want: map[string]any{"anything": 1},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := tc.node.ValidateOutput(tc.output)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("ValidateOutput: expected error, got nil (out=%v)", got)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("ValidateOutput: unexpected error: %v", err)
|
|
}
|
|
if diff := cmp.Diff(tc.want, got); diff != "" {
|
|
t.Errorf("ValidateOutput mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustSchema[T any](t *testing.T) *jsonschema.Schema {
|
|
t.Helper()
|
|
s, err := jsonschema.For[T](nil)
|
|
if err != nil {
|
|
t.Fatalf("jsonschema.For failed: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestFunctionNodeDirectEventPropagation(t *testing.T) {
|
|
fn := func(ctx agent.Context, input string) (*session.Event, error) {
|
|
ev := session.NewEvent(ctx, ctx.InvocationID())
|
|
ev.Output = input + " processed"
|
|
ev.Routes = []string{"CUSTOM_ROUTE"}
|
|
return ev, nil
|
|
}
|
|
|
|
node := NewFunctionNode[string, *session.Event]("event_proc", fn, defaultNodeConfig)
|
|
mockCtx := &MockInvocationContext{Context: t.Context(), sess: nil}
|
|
exCtx := agent.NewContext(mockCtx)
|
|
|
|
events := node.Run(exCtx, "hello")
|
|
|
|
var yieldedEvents []*session.Event
|
|
for ev, err := range events {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error running node: %v", err)
|
|
}
|
|
yieldedEvents = append(yieldedEvents, ev)
|
|
}
|
|
|
|
if len(yieldedEvents) != 1 {
|
|
t.Fatalf("expected 1 event, got %d", len(yieldedEvents))
|
|
}
|
|
|
|
ev := yieldedEvents[0]
|
|
if ev.Output != "hello processed" {
|
|
t.Errorf("expected Output 'hello processed', got %v", ev.Output)
|
|
}
|
|
if len(ev.Routes) != 1 || ev.Routes[0] != "CUSTOM_ROUTE" {
|
|
t.Errorf("expected Routes ['CUSTOM_ROUTE'], got %v", ev.Routes)
|
|
}
|
|
}
|
|
|
|
func TestNewFunctionNodeFromState(t *testing.T) {
|
|
type TwoFieldParams struct {
|
|
Foo string `state:"foo_key"`
|
|
Bar int
|
|
}
|
|
|
|
type NodeInputParams struct {
|
|
PredecessorOutput string `state:"node_input"`
|
|
OtherVal int `state:"other_val"`
|
|
}
|
|
|
|
type OutputStruct struct {
|
|
Message string
|
|
Code int
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
setupFn func() (*FunctionNode, error)
|
|
stateData map[string]any
|
|
input any
|
|
wantOutput any
|
|
wantErr bool
|
|
errSubstr string
|
|
wantStateFieldNames []string
|
|
}{
|
|
{
|
|
name: "Success",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test1", func(ctx agent.InvocationContext, p TwoFieldParams) (string, error) {
|
|
return fmt.Sprintf("%s-%d", p.Foo, p.Bar), nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"foo_key": "hello",
|
|
"Bar": 42,
|
|
},
|
|
input: nil,
|
|
wantOutput: "hello-42",
|
|
wantStateFieldNames: []string{"foo_key", "Bar"},
|
|
},
|
|
{
|
|
name: "Missing_state_key",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test2", func(ctx agent.InvocationContext, p TwoFieldParams) (string, error) {
|
|
return "", nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"foo_key": "hello",
|
|
// Bar missing
|
|
},
|
|
wantErr: true,
|
|
errSubstr: "missing state value for required field",
|
|
wantStateFieldNames: []string{"foo_key", "Bar"},
|
|
},
|
|
{
|
|
name: "Type_mismatch",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test3", func(ctx agent.InvocationContext, p TwoFieldParams) (string, error) {
|
|
return "", nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"foo_key": "hello",
|
|
"Bar": "not-an-int",
|
|
},
|
|
wantErr: true,
|
|
errSubstr: "failed to convert state value to field",
|
|
wantStateFieldNames: []string{"foo_key", "Bar"},
|
|
},
|
|
{
|
|
name: "Node_input_success",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test4", func(ctx agent.InvocationContext, p NodeInputParams) (string, error) {
|
|
return fmt.Sprintf("input:%s,other:%d", p.PredecessorOutput, p.OtherVal), nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"other_val": 100,
|
|
},
|
|
input: "from_pred",
|
|
wantOutput: "input:from_pred,other:100",
|
|
wantStateFieldNames: []string{"other_val"},
|
|
},
|
|
{
|
|
name: "Node_input_type_mismatch",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test5", func(ctx agent.InvocationContext, p NodeInputParams) (string, error) {
|
|
return "", nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"other_val": 100,
|
|
},
|
|
input: 123, // should be string
|
|
wantErr: true,
|
|
errSubstr: "invalid input type for node_input",
|
|
wantStateFieldNames: []string{"other_val"},
|
|
},
|
|
{
|
|
name: "Struct_output_success",
|
|
setupFn: func() (*FunctionNode, error) {
|
|
return NewFunctionNodeFromState("test6", func(ctx agent.InvocationContext, p TwoFieldParams) (OutputStruct, error) {
|
|
return OutputStruct{
|
|
Message: p.Foo,
|
|
Code: p.Bar,
|
|
}, nil
|
|
}, defaultNodeConfig)
|
|
},
|
|
stateData: map[string]any{
|
|
"foo_key": "hello",
|
|
"Bar": 42,
|
|
},
|
|
input: nil,
|
|
wantOutput: OutputStruct{Message: "hello", Code: 42},
|
|
wantStateFieldNames: []string{"foo_key", "Bar"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
node, err := tc.setupFn()
|
|
if err != nil {
|
|
t.Fatalf("setupFn failed: %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(tc.wantStateFieldNames, node.StateFieldNames()); diff != "" {
|
|
t.Errorf("StateFieldNames mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
mockSess := &mockSessionForTest{
|
|
state: &mockStateForTest{data: tc.stateData},
|
|
}
|
|
mockCtx := &MockInvocationContext{Context: t.Context(), sess: mockSess}
|
|
exCtx := agent.NewContext(mockCtx)
|
|
|
|
events := node.Run(exCtx, tc.input)
|
|
|
|
var lastErr error
|
|
var output any
|
|
for ev, err := range events {
|
|
if err != nil {
|
|
lastErr = err
|
|
break
|
|
}
|
|
output = ev.Output
|
|
}
|
|
|
|
if tc.wantErr {
|
|
if lastErr == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.errSubstr)
|
|
}
|
|
if tc.errSubstr != "" && !strings.Contains(lastErr.Error(), tc.errSubstr) {
|
|
t.Errorf("expected error containing %q, got %v", tc.errSubstr, lastErr)
|
|
}
|
|
} else {
|
|
if lastErr != nil {
|
|
t.Fatalf("unexpected error: %v", lastErr)
|
|
}
|
|
if diff := cmp.Diff(tc.wantOutput, output); diff != "" {
|
|
t.Errorf("output mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type mockStateForTest struct {
|
|
data map[string]any
|
|
}
|
|
|
|
func (m *mockStateForTest) Get(key string) (any, error) {
|
|
if val, ok := m.data[key]; ok {
|
|
return val, nil
|
|
}
|
|
return nil, session.ErrStateKeyNotExist
|
|
}
|
|
|
|
func (m *mockStateForTest) Set(key string, val any) error {
|
|
m.data[key] = val
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStateForTest) All() iter.Seq2[string, any] {
|
|
return func(yield func(string, any) bool) {
|
|
for k, v := range m.data {
|
|
if !yield(k, v) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type mockSessionForTest struct {
|
|
session.Session
|
|
state session.State
|
|
}
|
|
|
|
func (m *mockSessionForTest) State() session.State {
|
|
return m.state
|
|
}
|
|
|
|
// A body that emits a RequestInput and returns ErrNodeInterrupted
|
|
// pauses the workflow: the prompt is forwarded and the successor does
|
|
// not run.
|
|
func TestEmittingFunctionNode_HitlPausesAndForwardsRequest(t *testing.T) {
|
|
mockCtx := newSeededMockCtx(t)
|
|
|
|
var downstreamRan atomic.Bool
|
|
asker := NewEmittingFunctionNode("asker", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
|
|
if err := emit(NewRequestInputEvent(ctx, session.RequestInput{
|
|
InterruptID: "human_review",
|
|
Message: "Please approve",
|
|
Payload: "the draft",
|
|
})); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, ErrNodeInterrupted
|
|
}, defaultNodeConfig)
|
|
downstream := newHitlNode("downstream", func(_ agent.Context, _ any, _ func(*session.Event, error) bool) {
|
|
downstreamRan.Store(true)
|
|
})
|
|
|
|
w := mustNew(t, []Edge{
|
|
{From: Start, To: asker},
|
|
{From: asker, To: downstream},
|
|
})
|
|
|
|
events := drain(t, w.Run(mockCtx))
|
|
|
|
if downstreamRan.Load() {
|
|
t.Error("downstream node ran; HITL pause must suppress successor scheduling")
|
|
}
|
|
req, count := findRequestedInputEvent(events)
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly 1 RequestedInput event, got %d", count)
|
|
}
|
|
if got, want := req.RequestedInput.InterruptID, "human_review"; got != want {
|
|
t.Errorf("InterruptID = %q, want %q", got, want)
|
|
}
|
|
if got, want := req.RequestedInput.Message, "Please approve"; got != want {
|
|
t.Errorf("Message = %q, want %q", got, want)
|
|
}
|
|
if got, want := req.RequestedInput.Payload, "the draft"; got != want {
|
|
t.Errorf("Payload = %v, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// When the body emits a RequestInput without an InterruptID, the
|
|
// engine assigns one so the resume can still be correlated.
|
|
func TestEmittingFunctionNode_AutoGeneratesInterruptID(t *testing.T) {
|
|
mockCtx := newSeededMockCtx(t)
|
|
|
|
asker := NewEmittingFunctionNode("asker", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
|
|
if err := emit(NewRequestInputEvent(ctx, session.RequestInput{Message: "approve?"})); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, ErrNodeInterrupted
|
|
}, defaultNodeConfig)
|
|
w := mustNew(t, []Edge{{From: Start, To: asker}})
|
|
|
|
events := drain(t, w.Run(mockCtx))
|
|
req, count := findRequestedInputEvent(events)
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 RequestedInput event, got %d", count)
|
|
}
|
|
if req.RequestedInput.InterruptID == "" {
|
|
t.Error("InterruptID is empty; engine must auto-generate when caller omits it")
|
|
}
|
|
}
|
|
|
|
// Two RequestInputs emitted in a single activation both register on
|
|
// the parked node, so either response can resume it.
|
|
func TestEmittingFunctionNode_MultipleRequestsPark(t *testing.T) {
|
|
mockCtx := newSeededMockCtx(t)
|
|
|
|
asker := NewEmittingFunctionNode("asker", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
|
|
if err := emit(NewRequestInputEvent(ctx, session.RequestInput{InterruptID: "first"})); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := emit(NewRequestInputEvent(ctx, session.RequestInput{InterruptID: "second"})); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, ErrNodeInterrupted
|
|
}, defaultNodeConfig)
|
|
|
|
w := mustNew(t, []Edge{{From: Start, To: asker}})
|
|
events := drain(t, w.Run(mockCtx))
|
|
|
|
got := map[string]bool{}
|
|
for _, ev := range events {
|
|
for _, id := range ev.LongRunningToolIDs {
|
|
got[id] = true
|
|
}
|
|
}
|
|
if !got["first"] || !got["second"] {
|
|
t.Errorf("long-running interrupts = %v, want both first and second", got)
|
|
}
|
|
}
|
|
|
|
// A non-sentinel error returned after emitting a RequestInput fails
|
|
// the node; the recorded request must not turn the failure into a
|
|
// clean pause.
|
|
func TestEmittingFunctionNode_ErrorAfterRequestFails(t *testing.T) {
|
|
mockCtx := newSeededMockCtx(t)
|
|
|
|
wantErr := errors.New("downstream of request")
|
|
asker := NewEmittingFunctionNode("asker", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
|
|
if err := emit(NewRequestInputEvent(ctx, session.RequestInput{InterruptID: "ignored"})); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, wantErr
|
|
}, defaultNodeConfig)
|
|
w := mustNew(t, []Edge{{From: Start, To: asker}})
|
|
|
|
gotErr := drainErr(t, w.Run(mockCtx))
|
|
if !errors.Is(gotErr, wantErr) {
|
|
t.Errorf("Run error = %v, want %v (failure must take precedence over the recorded request)", gotErr, wantErr)
|
|
}
|
|
}
|
|
|
|
// A body can emit intermediate content-only events and still return a
|
|
// terminal output: both events surface and the successor runs (no
|
|
// pause). Intermediate events leave Output unset to respect the
|
|
// scheduler's single-output-per-node invariant.
|
|
func TestEmittingFunctionNode_EmitProgressBeforeOutput(t *testing.T) {
|
|
mockCtx := newSeededMockCtx(t)
|
|
|
|
var downstreamRan atomic.Bool
|
|
worker := NewEmittingFunctionNode("worker", func(ctx agent.Context, _ any, emit func(*session.Event) error) (string, error) {
|
|
progress := session.NewEvent(ctx, ctx.InvocationID())
|
|
progress.Content = &genai.Content{Parts: []*genai.Part{{Text: "tick"}}}
|
|
if err := emit(progress); err != nil {
|
|
return "", err
|
|
}
|
|
return "done", nil
|
|
}, defaultNodeConfig)
|
|
downstream := newHitlNode("downstream", func(_ agent.Context, _ any, _ func(*session.Event, error) bool) {
|
|
downstreamRan.Store(true)
|
|
})
|
|
|
|
w := mustNew(t, []Edge{
|
|
{From: Start, To: worker},
|
|
{From: worker, To: downstream},
|
|
})
|
|
events := drain(t, w.Run(mockCtx))
|
|
|
|
if !downstreamRan.Load() {
|
|
t.Error("downstream did not run; emit without ErrNodeInterrupted must not pause")
|
|
}
|
|
var sawProgress, sawDone bool
|
|
for _, ev := range events {
|
|
if ev == nil {
|
|
continue
|
|
}
|
|
if ev.Content != nil {
|
|
for _, p := range ev.Content.Parts {
|
|
if p != nil && p.Text == "tick" {
|
|
sawProgress = true
|
|
}
|
|
}
|
|
}
|
|
if s, ok := ev.Output.(string); ok && s == "done" {
|
|
sawDone = true
|
|
}
|
|
}
|
|
if !sawProgress {
|
|
t.Error(`did not observe progress event with Content text "tick"`)
|
|
}
|
|
if !sawDone {
|
|
t.Error(`did not observe terminal event with Output="done"`)
|
|
}
|
|
}
|
|
|
|
func TestEmittingFunctionNode_StopsAfterConsumerExits(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fn EmittingFunctionFn[any, string]
|
|
}{
|
|
{
|
|
name: "returns emit error",
|
|
fn: func(_ agent.Context, _ any, emit func(*session.Event) error) (string, error) {
|
|
if err := emit(&session.Event{}); err != nil {
|
|
return "", err
|
|
}
|
|
return "done", nil
|
|
},
|
|
},
|
|
{
|
|
name: "ignores emit error",
|
|
fn: func(_ agent.Context, _ any, emit func(*session.Event) error) (string, error) {
|
|
_ = emit(&session.Event{})
|
|
return "done", nil
|
|
},
|
|
},
|
|
{
|
|
name: "returns independent error",
|
|
fn: func(_ agent.Context, _ any, emit func(*session.Event) error) (string, error) {
|
|
_ = emit(&session.Event{})
|
|
return "", errors.New("body failed")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
worker := NewEmittingFunctionNode("worker", test.fn, defaultNodeConfig)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("FunctionNode.Run panicked after consumer break: %v", r)
|
|
}
|
|
}()
|
|
|
|
count := 0
|
|
for _, err := range worker.Run(agent.NewContext(newSeededMockCtx(t)), nil) {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
count++
|
|
break
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("consumed %d events, want 1", count)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Input schema constraints (e.g. maxLength) must be enforced even when
|
|
// the input already arrives as type IN, i.e. on the type-assertion-hit
|
|
// path that skips ConvertToWithJSONSchema.
|
|
func TestFunctionNode_ValidatesInputOnAssertionHitPath(t *testing.T) {
|
|
type Input struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
maxLen := 3
|
|
inputSchema := &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"name": {Type: "string", MaxLength: &maxLen},
|
|
},
|
|
}
|
|
|
|
fn := func(_ agent.Context, in Input) (string, error) { return in.Name, nil }
|
|
emittingFn := func(_ agent.Context, in Input, _ func(*session.Event) error) (any, error) {
|
|
return in.Name, nil
|
|
}
|
|
|
|
runOnce := func(t *testing.T, node *FunctionNode, input Input) error {
|
|
t.Helper()
|
|
exCtx := agent.NewContext(newMockCtx(t))
|
|
for _, err := range node.Run(exCtx, input) {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
t.Run("plain", func(t *testing.T) {
|
|
node, err := NewFunctionNodeWithSchema[Input, string]("greet", fn, inputSchema, nil, defaultNodeConfig)
|
|
if err != nil {
|
|
t.Fatalf("NewFunctionNodeWithSchema: %v", err)
|
|
}
|
|
if err := runOnce(t, node, Input{Name: "ok"}); err != nil {
|
|
t.Errorf("valid input rejected: %v", err)
|
|
}
|
|
err = runOnce(t, node, Input{Name: "too-long"})
|
|
if err == nil || !strings.Contains(err.Error(), "validation failed for input") {
|
|
t.Errorf("over-length input: err = %v, want validation failure", err)
|
|
}
|
|
})
|
|
|
|
t.Run("emitting", func(t *testing.T) {
|
|
node, err := NewEmittingFunctionNodeWithSchema[Input, any]("greet", emittingFn, inputSchema, nil, defaultNodeConfig)
|
|
if err != nil {
|
|
t.Fatalf("NewEmittingFunctionNodeWithSchema: %v", err)
|
|
}
|
|
if err := runOnce(t, node, Input{Name: "ok"}); err != nil {
|
|
t.Errorf("valid input rejected: %v", err)
|
|
}
|
|
err = runOnce(t, node, Input{Name: "too-long"})
|
|
if err == nil || !strings.Contains(err.Error(), "validation failed for input") {
|
|
t.Errorf("over-length input: err = %v, want validation failure", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// runFunctionNodeOnce drives a FunctionNode for a single input and
|
|
// returns the sole emitted event.
|
|
func runFunctionNodeOnce[OUT any](t *testing.T, fn func(ctx agent.Context, input any) (OUT, error), input any) *session.Event {
|
|
t.Helper()
|
|
node := NewFunctionNode[any, OUT]("n", fn, defaultNodeConfig)
|
|
exCtx := agent.NewContext(&MockInvocationContext{Context: t.Context(), sess: nil})
|
|
|
|
var got *session.Event
|
|
count := 0
|
|
for ev, err := range node.Run(exCtx, input) {
|
|
if err != nil {
|
|
t.Fatalf("FunctionNode.Run: %v", err)
|
|
}
|
|
got = ev
|
|
count++
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("FunctionNode.Run emitted %d events, want 1", count)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// TestFunctionNode_ContentOutputGoesToContent asserts that a
|
|
// *genai.Content (or genai.Content) return populates event.Content, not
|
|
// event.Output. Mirrors adk-python _function_node.py, which maps a
|
|
// Content return to Event(content=...).
|
|
func TestFunctionNode_ContentOutputGoesToContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
want := &genai.Content{
|
|
Role: "model",
|
|
Parts: []*genai.Part{genai.NewPartFromText("hello from content")},
|
|
}
|
|
|
|
t.Run("pointer *genai.Content", func(t *testing.T) {
|
|
t.Parallel()
|
|
ev := runFunctionNodeOnce(t, func(ctx agent.Context, _ any) (*genai.Content, error) {
|
|
return want, nil
|
|
}, nil)
|
|
|
|
if ev.Output != nil {
|
|
t.Errorf("event.Output = %v, want nil; Content must not go to Output", ev.Output)
|
|
}
|
|
if diff := cmp.Diff(want, ev.Content); diff != "" {
|
|
t.Errorf("event.Content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
t.Run("value genai.Content", func(t *testing.T) {
|
|
t.Parallel()
|
|
ev := runFunctionNodeOnce(t, func(ctx agent.Context, _ any) (genai.Content, error) {
|
|
return *want, nil
|
|
}, nil)
|
|
|
|
if ev.Output != nil {
|
|
t.Errorf("event.Output = %v, want nil; Content must not go to Output", ev.Output)
|
|
}
|
|
if diff := cmp.Diff(want, ev.Content); diff != "" {
|
|
t.Errorf("event.Content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFunctionNode_NonContentOutputGoesToOutput pins the complementary
|
|
// case: a non-Content return value populates event.Output and leaves
|
|
// event.Content nil.
|
|
func TestFunctionNode_NonContentOutputGoesToOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
want := map[string]any{"result": "ok"}
|
|
ev := runFunctionNodeOnce(t, func(ctx agent.Context, _ any) (map[string]any, error) {
|
|
return want, nil
|
|
}, nil)
|
|
|
|
if ev.Content != nil {
|
|
t.Errorf("event.Content = %v, want nil; non-Content output must not set Content", ev.Content)
|
|
}
|
|
if diff := cmp.Diff(want, ev.Output); diff != "" {
|
|
t.Errorf("event.Output mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|