From 9b5c7d8f7ed74cf5a964ddc2466418d4384520bb Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sun, 13 Sep 2026 16:26:17 +0530 Subject: [PATCH] Accept interface send type with a concrete implementing target in type validation sendTypeCompatibleWithInput checked outType==inType, outType.AssignableTo(inType), and (inType is interface && outType implements inType), but not the symmetric inType.AssignableTo(outType). Type-set overlap is symmetric, so when the source declares an interface send type and the target accepts a concrete type that implements it, the edge is runtime-valid (the source may emit that concrete value, and the router resolves it to the concrete handler) - yet Build() rejected it with a type incompatibility error. This hits the common message.Content-source to *message.TextContent-target pattern. Add inType.AssignableTo(outType); it does not over-accept (two unrelated concrete types remain non-assignable either direction), and the third clause it replaces was already redundant with outType.AssignableTo(inType). --- workflow/builder.go | 7 ++++++- workflow/builder_test.go | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/workflow/builder.go b/workflow/builder.go index 613a5141..2d3821c6 100644 --- a/workflow/builder.go +++ b/workflow/builder.go @@ -468,7 +468,12 @@ func sendTypeCompatibleWithInput(outType, inType reflect.Type) bool { if outType == reflect.TypeFor[any]() { return true } - return outType == inType || outType.AssignableTo(inType) || (inType.Kind() == reflect.Interface && outType.Implements(inType)) + // Compatibility (type-set overlap) is symmetric: the edge is valid when a + // concrete value can satisfy both sides. Also accept the case where the + // target input type is assignable to the sent type - e.g. an interface send + // type with a concrete target that implements it (source may emit that + // concrete value). + return outType == inType || outType.AssignableTo(inType) || inType.AssignableTo(outType) } func (wb *Builder) trackInputPort(port RequestPort) bool { diff --git a/workflow/builder_test.go b/workflow/builder_test.go index 563f5aea..5af2b23b 100644 --- a/workflow/builder_test.go +++ b/workflow/builder_test.go @@ -1027,3 +1027,24 @@ func TestBuilder_ConditionalEdgeDoesNotDropIdempotentConditionlessEdge(t *testin t.Fatalf("edges from start: conditional=%d conditionless=%d, want 1 and 1", conditional, conditionless) } } + +type shapeForTypeCompat interface{ area() float64 } + +type circleForTypeCompat struct{} + +func (circleForTypeCompat) area() float64 { return 1 } + +func TestBuilder_Validation_TypeCompatibility_InterfaceSendConcreteTarget(t *testing.T) { + // Source declares it sends the interface type; target accepts a concrete + // type that implements it. The source may emit that concrete value, so the + // edge is valid and must build (type-set overlap is symmetric). + source := newDeclaredSendExecutor[string]("source", reflect.TypeFor[shapeForTypeCompat]()) + target := newTypedExecutor[circleForTypeCompat, string]("target") + + _, err := workflow.NewBuilder(source). + AddEdge(source, target). + Build() + if err != nil { + t.Fatalf("expected interface-send/concrete-target edge to build, got: %v", err) + } +}