diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 3c304cc9..82a0a4e0 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -303,6 +303,21 @@ func createTypedConfigRequest(descriptor *pb.ContractDescriptor, cfg map[string] } return s, nil, nil } + // Contracts that declare a typed Mode (STRICT_PROTO or + // PROTO_WITH_LEGACY_STRUCT) but leave ConfigMessage empty have no + // per-instance config schema — primarily input-only steps like + // step.eventbus.ack/publish/consume where data flows through the + // InputMessage proto, but also applies to any contract Kind that + // legitimately omits a config schema. Encode cfg as legacy struct + // only; typed payload is nil. The plugin's typed factory reads data + // from the input message (or other typed payload), not from config. + if descriptor.ConfigMessage == "" { + s, err := mapToStruct(cfg) + if err != nil { + return nil, nil, fmt.Errorf("encode config as Struct (no typed config schema): %w", err) + } + return s, nil, nil + } // Strip engine-internal "_"-prefix keys before proto decode. STRICT_PROTO // and PROTO_WITH_LEGACY_STRUCT modules use protojson with DiscardUnknown // = false (convert.go:62), which rejects engine internals like diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index 8f1f54e4..2d723f81 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -1022,6 +1022,48 @@ func TestCreateTypedConfigRequestStripsInternalKeysForStrictProtoStep(t *testing } } +// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers +// contracts that declare STRICT_PROTO with InputMessage + OutputMessage but +// no ConfigMessage (input-only steps like step.eventbus.ack / +// step.eventbus.publish). The engine must NOT attempt to encode an +// unnamed typed proto; typed payload is nil, legacy struct mirrors cfg +// (nil cfg → nil legacy via mapToStruct(nil); non-nil cfg → populated +// struct). +func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) { + descriptor := &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.ack", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + InputMessage: "workflow.plugin.eventbus.v1.AckRequest", + OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", + // ConfigMessage intentionally empty — step has no per-instance + // config schema; data flows via the input message. + } + // nil cfg — mapToStruct(nil) returns nil; legacy is permitted to be nil. + legacy, typed, err := createTypedConfigRequest(descriptor, nil, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with nil cfg + empty ConfigMessage: %v", err) + } + if typed != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed) + } + if legacy != nil { + t.Fatalf("expected nil legacy struct for nil cfg; got %v", legacy.Fields) + } + // Non-nil cfg — fields populated into legacy struct; typed still nil. + cfg := map[string]any{"timeout_ms": float64(5000)} + legacy2, typed2, err := createTypedConfigRequest(descriptor, cfg, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with cfg + empty ConfigMessage: %v", err) + } + if typed2 != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed2) + } + if legacy2 == nil || legacy2.Fields["timeout_ms"] == nil { + t.Fatalf("expected legacy struct with timeout_ms populated; got %v", legacy2) + } +} + // TestCreateTypedConfigRequestRetainsInternalKeysInLegacyStruct asserts the // legacy-struct path keeps "_"-prefix keys on its *structpb.Struct payload. // Legacy modules consume "_config_dir" at the plugin side to resolve filesystem-